From 46d3727570006a32a6d755b002e2f99c922efd70 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Wed, 26 Aug 2026 17:05:41 -0400 Subject: [PATCH 1/7] add `path share --all` for bulk upload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `share --all` uploads every session across the installed harnesses instead of opening the picker. `--project-under ` restricts it to sessions under that subtree (same per-provider matching as `p cache sync`); `--harness` narrows to one harness. It prints one line per project directory — session count, per-harness breakdown, and the configured remote where a `[[project]]` rule resolves — and asks before uploading (`--yes` skips the prompt, `--dry-run` stops after the summary). Destinations follow the single-session rules: `--repo` for everything, else each directory's configured remote, else `/pathstash`. Requires login. Failures warn and continue; the run exits non-zero if any failed. Nothing records what was already shared, so re-running uploads everything again. Claude and pi decode their project directories from lossy slugs, so the summary showed wrong paths and `[[project]]` rules on directories containing `_`, `.`, or `-` never matched. The two metadata readers now also return the cwd the session file records (`ConversationMetadata.cwd`, `SessionMeta.cwd`), and share — both the picker and `--all` — groups, displays, and resolves remotes by that. The slug-decoded path is still what locates the session files. path-cli 0.19.0, toolpath-claude 0.13.1, toolpath-pi 0.6.2. --- CHANGELOG.md | 27 + CLAUDE.md | 5 +- Cargo.lock | 6 +- Cargo.toml | 6 +- crates/path-cli/Cargo.toml | 2 +- crates/path-cli/src/cmd_export.rs | 2 +- crates/path-cli/src/cmd_share.rs | 721 +++++++++++++++++++++++---- crates/path-cli/src/sync/sources.rs | 12 + crates/toolpath-claude/Cargo.toml | 2 +- crates/toolpath-claude/src/lib.rs | 5 + crates/toolpath-claude/src/reader.rs | 6 + crates/toolpath-claude/src/types.rs | 5 + crates/toolpath-pi/Cargo.toml | 2 +- crates/toolpath-pi/src/io.rs | 25 +- crates/toolpath-pi/src/reader.rs | 4 + site/_data/crates.json | 6 +- 16 files changed, 714 insertions(+), 122 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eecd9e2..9d215ad9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,33 @@ All notable changes to the Toolpath workspace are documented here. +## Bulk share with `path share --all` — 2026-08-26 + +- **`path-cli`** (0.19.0): `path share --all` uploads every session + across the installed harnesses instead of opening the picker. + `--project-under ` restricts it to sessions whose project + directory is under that subtree (same matching as `p cache sync`); + `--harness` narrows to one harness. Before uploading it prints one + line per project directory — session count, per-harness breakdown, + and the configured remote where a `[[project]]` rule resolves — and + asks for confirmation (`--yes` skips the prompt, `--dry-run` stops + after the summary). Destinations follow the single-session rules: + `--repo` for everything, else each directory's configured remote, + else `/pathstash`. Requires login; `--anon` is rejected. Uploads + are sequential, failures warn and continue, and the run exits + non-zero if any failed. Nothing records what was already shared, so + re-running uploads everything again. +- **`toolpath-claude`** (0.13.1): `ConversationMetadata.cwd` — the + working directory the session recorded, captured during the metadata + pass. `project_path` is decoded from the directory slug, which is + lossy (`_` and `.` come back as `/`); `cwd` is the real path. +- **`toolpath-pi`** (0.6.2): `SessionMeta.cwd` from the session header, + for the same reason — the session-dir name turns every `-` into `/`. +- **`path-cli`**: `share` (the picker and `--all`) groups, displays, + and resolves configured remotes for claude and pi sessions by their + recorded cwd rather than the slug-decoded project, so `[[project]]` + rules on directories containing `_`, `.`, or `-` match them. + ## toolpath-claude 0.13.0 — 2026-08-23 - **Breaking:** `Conversation.segment_ids` replaces `Conversation.session_ids`. diff --git a/CLAUDE.md b/CLAUDE.md index 51f0f467..f774ad30 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -93,6 +93,7 @@ cargo run -p path-cli -- p import claude --project . --no-cache | path p render # Share an agent session to Pathbase (interactive picker, single-shot) cargo run -p path-cli -- share cargo run -p path-cli -- share --harness claude --session --project /path/to/project +cargo run -p path-cli -- share --all --project-under ~/work # bulk upload; --dry-run / --yes # Resume a Toolpath document into a coding agent (interactive harness picker) cargo run -p path-cli -- resume @@ -216,8 +217,8 @@ Format references for the agent on-disk formats live at `docs/agents/formats/` ### CLI behaviors - Interactive pickers: `p import ` auto-launches a fuzzy picker when TTY and no `--session`. Backend: external `fzf` if present, else the embedded skim picker (`embedded-picker` default feature, `crates/path-cli/src/skim_picker.rs`). Multi-select produces a `Graph`; single-select a `Path`. No usable backend falls back to most-recent (with `--project`) or prints the manual recipe. `p list --format tsv` is the machine-readable surface; the trailing column carries `first_user_message`. -- `path share` is the one-shot `p import | p export pathbase`: probes installed harnesses, aggregates sessions into one picker ranking current-directory sessions first; `--harness`/`--session`/`--project` skip the picker; pathbase flags match `p export pathbase`. When the sync manifest shows the picked session unchanged (`sync::fresh_cache_id`), it uploads the cached doc instead of re-deriving. Uploads carry the same full derivation as local projection — no egress stripping. -- Share also resolves a **configured share remote** from the session's own directory (the project for path-keyed harnesses, the recorded cwd otherwise — via the doc's `path.base` when no `--project` is in play): `crates/path-cli/src/share_config.rs` checks `~/.toolpath/config.toml` `[[project]]` rules (`dir` subtree match, `~/`-expandable, most specific wins; `remote` = bare `owner/name` or a canonical Pathbase repo web URL `https:///u//`, which also carries the server — the URL scheme is the extension point for future backends, unknown schemes rejected). Precedence: `--repo` flag > config > `/pathstash`; `--url` beats a URL remote's embedded server. A resolved remote prints `Sharing to ()`; hitting one while logged out errors with a `path auth login` hint (no silent anon fall-through), and explicit `--anon` opts out of the mapping. Both sides of the subtree match are prefix-canonicalized (longest existing ancestor resolved, tail re-appended) so macOS `/var`→`/private/var` and deleted checkouts still match. A repo-tracked `.toolpath.toml` is deliberately **not** consulted — a committed file redirecting other users' uploads needs a first-use consent flow (issue #179). +- `path share` is the one-shot `p import | p export pathbase`: probes installed harnesses, aggregates sessions into one picker ranking current-directory sessions first; `--harness`/`--session`/`--project` skip the picker; pathbase flags match `p export pathbase`. When the sync manifest shows the picked session unchanged (`sync::fresh_cache_id`), it uploads the cached doc instead of re-deriving. Uploads carry the same full derivation as local projection — no egress stripping. `share --all` skips the picker and uploads every session in scope (`--project-under` subtree, `--harness`), printing a per-project summary and confirming first; it needs login and keeps no record of prior uploads. +- Share also resolves a **configured share remote** from the session's own directory (the recorded cwd wherever the provider reports one — claude/pi carry it on `ConversationMetadata.cwd`/`SessionMeta.cwd` because their slug-decoded project paths are lossy; else `--project`; else the doc's `path.base`): `crates/path-cli/src/share_config.rs` checks `~/.toolpath/config.toml` `[[project]]` rules (`dir` subtree match, `~/`-expandable, most specific wins; `remote` = bare `owner/name` or a canonical Pathbase repo web URL `https:///u//`, which also carries the server — the URL scheme is the extension point for future backends, unknown schemes rejected). Precedence: `--repo` flag > config > `/pathstash`; `--url` beats a URL remote's embedded server. A resolved remote prints `Sharing to ()`; hitting one while logged out errors with a `path auth login` hint (no silent anon fall-through), and explicit `--anon` opts out of the mapping. Both sides of the subtree match are prefix-canonicalized (longest existing ancestor resolved, tail re-appended) so macOS `/var`→`/private/var` and deleted checkouts still match. A repo-tracked `.toolpath.toml` is deliberately **not** consulted — a committed file redirecting other users' uploads needs a first-use consent flow (issue #179). - `path resume ` is the inverse: accepts a Pathbase URL, `owner/repo/slug` shorthand, local file, or cache id; validates a single agent-bearing `Path`; opens a harness picker (pre-selecting `path.meta.source` when installed; `--harness` skips); projects the session into the harness's on-disk layout under `-C/--cwd` (default: shell cwd) and `execvp`'s the harness's resume command (spawn-and-wait on Windows). - `path query` plans before it scans: `crates/path-cli/src/query/plan.rs` classifies the jaq filter into `PerFileStream` (element-wise, print as you go), `Decompose` (algebraic aggregation with a derived combine), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative so **the planner never changes an answer**; `query/filter.rs` tests assert streamed output equals slurp byte-for-byte. Execution is parallel (`mod.rs::execute_plan`/`for_each_file`): `PerFileStream`/`Decompose` run the whole per-file pipeline (parse → wrap → filter → render/pack) on rayon workers in chunks — partials cross threads as compact JSON bytes since jaq `Val`s are `Rc`-based — while `Slurp` parallelizes only parse/wrap; output stays byte-identical to a sequential scan (ordering, warnings, error precedence), and the emscripten build stays fully sequential. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan; there is no user-facing flag. Caveat: a streamed top-N matches slurp's ranking, but boundary ties may resolve to different rows. diff --git a/Cargo.lock b/Cargo.lock index 77131a28..0c5cf52d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2487,7 +2487,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.18.0" +version = "0.19.0" dependencies = [ "anyhow", "assert_cmd", @@ -4226,7 +4226,7 @@ dependencies = [ [[package]] name = "toolpath-claude" -version = "0.13.0" +version = "0.13.1" dependencies = [ "anyhow", "chrono", @@ -4372,7 +4372,7 @@ dependencies = [ [[package]] name = "toolpath-pi" -version = "0.6.1" +version = "0.6.2" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 5944d9e9..8af01ff1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -27,7 +27,7 @@ license = "Apache-2.0" toolpath = { version = "0.7.1", path = "crates/toolpath" } toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" } toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" } -toolpath-claude = { version = "0.13.0", path = "crates/toolpath-claude", default-features = false } +toolpath-claude = { version = "0.13.1", path = "crates/toolpath-claude", default-features = false } toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false } toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" } toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" } @@ -36,8 +36,8 @@ toolpath-cursor = { version = "0.2.0", path = "crates/toolpath-cursor" } toolpath-github = { version = "0.6.0", path = "crates/toolpath-github" } toolpath-dot = { version = "0.5.0", path = "crates/toolpath-dot" } toolpath-md = { version = "0.7.0", path = "crates/toolpath-md" } -toolpath-pi = { version = "0.6.1", path = "crates/toolpath-pi" } -path-cli = { version = "0.18.0", path = "crates/path-cli" } +toolpath-pi = { version = "0.6.2", path = "crates/toolpath-pi" } +path-cli = { version = "0.19.0", path = "crates/path-cli" } pathbase-client = { version = "0.2.0", path = "crates/pathbase-client" } reqwest = { version = "0.13", default-features = false, features = ["blocking", "json", "rustls"] } diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 27bda67b..4a2db9f4 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.18.0" +version = "0.19.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 68805fd5..2cf3a184 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -2033,7 +2033,7 @@ pub(crate) fn run_pathbase_inner( /// hash the canonical JSON and use a short hex prefix so re-uploads /// of the same content produce the same display label. #[cfg(not(target_os = "emscripten"))] -fn derive_name(doc: &toolpath::v1::Graph) -> String { +pub(crate) fn derive_name(doc: &toolpath::v1::Graph) -> String { let raw = match doc.single_path() { Some(p) => p.path.id.as_str(), None => doc.graph.id.as_str(), diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 0d6ba9c4..470a6108 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -57,6 +57,51 @@ pub struct ShareArgs { /// Skip writing the cache; derive in-memory only #[arg(long)] pub no_cache: bool, + + /// Upload every session instead of picking one. Requires login. + /// Every run uploads everything in scope — there is no record of + /// what was shared before, so re-running creates duplicate graphs. + #[arg(long, conflicts_with_all = ["session", "anon", "project"])] + pub all: bool, + + /// With --all: only sessions whose project directory is under this path + #[arg(long, requires = "all", value_name = "DIR")] + pub project_under: Option, + + /// With --all: print what would be uploaded and exit + #[arg(long, requires = "all")] + pub dry_run: bool, + + /// With --all: skip the confirmation prompt + #[arg(long, short = 'y', requires = "all")] + pub yes: bool, +} + +/// Which sessions `gather_artifacts` keeps, by project directory. +#[derive(Debug, Clone, Copy)] +pub(crate) enum ProjectScope<'a> { + /// Sessions tied to exactly this directory (`--project`). + Exact(&'a std::path::Path), + /// Sessions whose directory is this one or below it (`--project-under`). + Under(&'a std::path::Path), +} + +impl ProjectScope<'_> { + fn admits(&self, t: ArtifactType, dir: &str) -> bool { + match self { + ProjectScope::Exact(p) => paths_match(std::path::Path::new(dir), p), + ProjectScope::Under(p) => crate::sync::sources::project_in_scope(t, dir, p), + } + } +} + +/// `None` scope admits everything, including sessions with no known +/// directory; any scope excludes those. +fn admits(scope: Option<&ProjectScope<'_>>, t: ArtifactType, dir: Option<&str>) -> bool { + match scope { + None => true, + Some(s) => dir.is_some_and(|d| s.admits(t, d)), + } } /// One artifact surfaced by a provider — today always an agent session. @@ -64,9 +109,12 @@ pub struct ShareArgs { #[derive(Debug, Clone)] pub(crate) struct ArtifactRow { pub(crate) artifact_type: ArtifactType, - /// Project path for keyed providers; `None` for codex/opencode. + /// Project path for keyed providers, as the provider keys it + /// (claude and pi decode theirs from a lossy slug); `None` for + /// session-keyed providers. pub(crate) path: Option, - /// Recorded cwd from the session (codex/opencode only). + /// Working directory the session recorded. The real directory + /// wherever the provider reports one; `None` when it doesn't. pub(crate) cwd: Option, pub(crate) session_id: String, pub(crate) title: String, @@ -81,55 +129,54 @@ pub(crate) struct ArtifactRow { /// rows whose project (or recorded cwd) canonicalizes to `cwd` come /// first, sorted by descending `last_activity`. /// -/// Filters: `harness_filter` keeps only rows from one harness; `project_filter` +/// Filters: `harness_filter` keeps only rows from one harness; `scope` /// keeps only rows whose project (for keyed) or cwd (for session-keyed) -/// canonicalizes to that path. +/// the scope admits. pub(crate) fn gather_artifacts( bundle: &HarnessBundle, cwd: &std::path::Path, harness_filter: Option, - project_filter: Option<&std::path::Path>, + scope: Option<&ProjectScope<'_>>, ) -> Vec { let mut rows = Vec::new(); let canonical_cwd = canonicalize_or_self(cwd); - let canonical_project = project_filter.map(canonicalize_or_self); let want = |h: ArtifactType| harness_filter.is_none_or(|f| f == h); if want(ArtifactType::Claude) && let Some(mgr) = &bundle.claude { - collect_claude(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); + collect_claude(mgr, &canonical_cwd, scope, &mut rows); } if want(ArtifactType::Gemini) && let Some(mgr) = &bundle.gemini { - collect_gemini(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); + collect_gemini(mgr, &canonical_cwd, scope, &mut rows); } if want(ArtifactType::Pi) && let Some(mgr) = &bundle.pi { - collect_pi(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); + collect_pi(mgr, &canonical_cwd, scope, &mut rows); } if want(ArtifactType::Codex) && let Some(mgr) = &bundle.codex { - collect_codex(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); + collect_codex(mgr, &canonical_cwd, scope, &mut rows); } if want(ArtifactType::Copilot) && let Some(mgr) = &bundle.copilot { - collect_copilot(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); + collect_copilot(mgr, &canonical_cwd, scope, &mut rows); } if want(ArtifactType::Opencode) && let Some(mgr) = &bundle.opencode { - collect_opencode(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); + collect_opencode(mgr, &canonical_cwd, scope, &mut rows); } if want(ArtifactType::Cursor) && let Some(mgr) = &bundle.cursor { - collect_cursor(mgr, &canonical_cwd, canonical_project.as_deref(), &mut rows); + collect_cursor(mgr, &canonical_cwd, scope, &mut rows); } rows.sort_by(|a, b| { @@ -151,7 +198,7 @@ fn paths_match(a: &std::path::Path, b: &std::path::Path) -> bool { fn collect_claude( mgr: &toolpath_claude::ClaudeConvo, canonical_cwd: &std::path::Path, - project_filter: Option<&std::path::Path>, + scope: Option<&ProjectScope<'_>>, out: &mut Vec, ) { let projects = match mgr.list_projects() { @@ -165,9 +212,7 @@ fn collect_claude( }; for project in projects { let project_path = std::path::Path::new(&project); - if let Some(filter) = project_filter - && !paths_match(project_path, filter) - { + if !admits(scope, ArtifactType::Claude, Some(&project)) { continue; } let metas = match mgr.list_conversation_metadata(&project) { @@ -179,10 +224,13 @@ fn collect_claude( }; let matches_cwd = paths_match(project_path, canonical_cwd); for m in metas { + // The slug-decoded project is what the provider keys on; + // the recorded cwd is the real directory for display, + // grouping, and remote lookup. out.push(ArtifactRow { artifact_type: ArtifactType::Claude, path: Some(m.project_path), - cwd: None, + cwd: m.cwd, session_id: m.session_id, title: m .first_user_message @@ -198,7 +246,7 @@ fn collect_claude( fn collect_gemini( mgr: &toolpath_gemini::GeminiConvo, canonical_cwd: &std::path::Path, - project_filter: Option<&std::path::Path>, + scope: Option<&ProjectScope<'_>>, out: &mut Vec, ) { let projects = match mgr.list_projects() { @@ -212,9 +260,7 @@ fn collect_gemini( }; for project in projects { let project_path = std::path::Path::new(&project); - if let Some(filter) = project_filter - && !paths_match(project_path, filter) - { + if !admits(scope, ArtifactType::Gemini, Some(&project)) { continue; } let metas = match mgr.list_conversation_metadata(&project) { @@ -245,7 +291,7 @@ fn collect_gemini( fn collect_pi( mgr: &toolpath_pi::PiConvo, canonical_cwd: &std::path::Path, - project_filter: Option<&std::path::Path>, + scope: Option<&ProjectScope<'_>>, out: &mut Vec, ) { let projects = match mgr.list_projects() { @@ -259,9 +305,7 @@ fn collect_pi( }; for project in projects { let project_path = std::path::Path::new(&project); - if let Some(filter) = project_filter - && !paths_match(project_path, filter) - { + if !admits(scope, ArtifactType::Pi, Some(&project)) { continue; } let metas = match mgr.list_sessions(&project) { @@ -289,7 +333,7 @@ fn collect_pi( out.push(ArtifactRow { artifact_type: ArtifactType::Pi, path: Some(project.clone()), - cwd: None, + cwd: m.cwd, session_id: m.id, title: m .first_user_message @@ -305,7 +349,7 @@ fn collect_pi( fn collect_codex( mgr: &toolpath_codex::CodexConvo, canonical_cwd: &std::path::Path, - project_filter: Option<&std::path::Path>, + scope: Option<&ProjectScope<'_>>, out: &mut Vec, ) { let metas = match mgr.list_sessions() { @@ -319,14 +363,8 @@ fn collect_codex( }; for m in metas { let cwd_str = m.cwd.as_ref().map(|p| p.to_string_lossy().into_owned()); - if let Some(filter) = project_filter { - let stored = match cwd_str.as_deref() { - Some(s) => std::path::PathBuf::from(s), - None => continue, - }; - if !paths_match(&stored, filter) { - continue; - } + if !admits(scope, ArtifactType::Codex, cwd_str.as_deref()) { + continue; } let matches_cwd = m .cwd @@ -351,7 +389,7 @@ fn collect_codex( fn collect_copilot( mgr: &toolpath_copilot::CopilotConvo, canonical_cwd: &std::path::Path, - project_filter: Option<&std::path::Path>, + scope: Option<&ProjectScope<'_>>, out: &mut Vec, ) { let metas = match mgr.list_sessions() { @@ -366,11 +404,8 @@ fn collect_copilot( for m in metas { // Copilot stores cwd as a String (from session.start `context.cwd`). let stored = m.cwd.as_deref().map(std::path::PathBuf::from); - if let Some(filter) = project_filter { - match &stored { - Some(p) if paths_match(p, filter) => {} - _ => continue, - } + if !admits(scope, ArtifactType::Copilot, m.cwd.as_deref()) { + continue; } let matches_cwd = stored .as_deref() @@ -394,7 +429,7 @@ fn collect_copilot( fn collect_opencode( mgr: &toolpath_opencode::OpencodeConvo, canonical_cwd: &std::path::Path, - project_filter: Option<&std::path::Path>, + scope: Option<&ProjectScope<'_>>, out: &mut Vec, ) { let metas = match mgr.io().list_session_metadata(None) { @@ -407,13 +442,11 @@ fn collect_opencode( } }; for m in metas { - if let Some(filter) = project_filter - && !paths_match(&m.directory, filter) - { + let cwd_str = m.directory.to_string_lossy().into_owned(); + if !admits(scope, ArtifactType::Opencode, Some(&cwd_str)) { continue; } let matches_cwd = paths_match(&m.directory, canonical_cwd); - let cwd_str = m.directory.to_string_lossy().into_owned(); let title = match (&m.first_user_message, m.title.is_empty()) { (Some(s), _) if !s.is_empty() => s.clone(), (_, false) => m.title.clone(), @@ -435,7 +468,7 @@ fn collect_opencode( fn collect_cursor( mgr: &toolpath_cursor::CursorConvo, canonical_cwd: &std::path::Path, - project_filter: Option<&std::path::Path>, + scope: Option<&ProjectScope<'_>>, out: &mut Vec, ) { let metas = match mgr.io().list_session_metadata() { @@ -456,13 +489,11 @@ fn collect_cursor( let Some(workspace) = m.workspace_path.as_ref() else { continue; }; - if let Some(filter) = project_filter - && !paths_match(workspace, filter) - { + let cwd_str = workspace.to_string_lossy().into_owned(); + if !admits(scope, ArtifactType::Cursor, Some(&cwd_str)) { continue; } let matches_cwd = paths_match(workspace, canonical_cwd); - let cwd_str = workspace.to_string_lossy().into_owned(); let title = match (&m.first_user_message, &m.name) { (Some(s), _) if !s.is_empty() => s.clone(), (_, Some(n)) if !n.is_empty() => n.clone(), @@ -505,13 +536,20 @@ pub fn run(args: ShareArgs) -> Result<()> { // Explicit-args: validate creds before derive so a credential // failure doesn't waste the derive/cache work. let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?; - return share_explicit(h, session.as_str(), &args, auth, base_url); + return share_explicit(h, session.as_str(), &args, auth, base_url, None); } let cwd = std::env::current_dir()?; let bundle = HarnessBundle::from_environment(); + + if args.all { + let auth = crate::cmd_pathbase::preflight_auth(&base_url, false, true)?; + return share_all(&args, harness, &bundle, &cwd, auth, base_url); + } + let project_filter = args.project.as_deref(); - let rows = gather_artifacts(&bundle, &cwd, harness, project_filter); + let scope = project_filter.map(ProjectScope::Exact); + let rows = gather_artifacts(&bundle, &cwd, harness, scope.as_ref()); if rows.is_empty() { return bail_no_sessions(&bundle, project_filter); @@ -579,12 +617,25 @@ pub fn run(args: ShareArgs) -> Result<()> { None }, no_cache: args.no_cache, + all: false, + project_under: None, + dry_run: false, + yes: false, }; // Show the conversation title in the confirmation line; the session id // is opaque and doesn't help the user verify they picked the right // thing. `{:?}` adds the surrounding quotes per the spec. eprintln!("Picked {} session {:?}", h.name(), title); - share_explicit(h, &session, &explicit, auth, base_url) + // The picker line carries the provider's project key, which for + // claude and pi is decoded from a lossy slug. The row still has + // the session's recorded cwd; that is the directory the configured + // remote should be looked up under. + let session_dir = rows + .iter() + .find(|r| r.artifact_type == h && r.session_id == session) + .and_then(|r| r.cwd.clone()) + .map(PathBuf::from); + share_explicit(h, &session, &explicit, auth, base_url, session_dir) } fn bail_no_sessions( @@ -761,12 +812,17 @@ fn harness_status_cursor(bundle: &HarnessBundle, home: Option<&std::path::Path>) } } +/// `session_dir` is the directory to resolve a configured remote +/// under when the caller knows it (the picker passes the row's +/// recorded cwd); otherwise it comes from `--project`, then from the +/// derived document's `path.base`. fn share_explicit( harness: ArtifactType, session: &str, args: &ShareArgs, auth: crate::cmd_pathbase::AuthMode, base_url: String, + session_dir: Option, ) -> Result<()> { let project = match (harness.path_keyed(), args.project.as_ref()) { (true, Some(p)) => Some(p.to_string_lossy().into_owned()), @@ -777,14 +833,57 @@ fn share_explicit( (false, _) => None, }; - // Fast path: when the manifest shows this exact source state is - // already in the cache, upload the cached doc instead of re-deriving - // — a derive would reproduce it byte-for-byte anyway. - if !args.no_cache + let loaded = load_session(harness, project.as_deref(), session, args.no_cache)?; + let summary = format!("{} session {}", harness.name(), loaded.cache_id); + let session_dir = session_dir + .or_else(|| project.as_deref().map(PathBuf::from)) + .or_else(|| { + loaded + .doc + .as_ref() + .map(std::borrow::Cow::Borrowed) + .or_else(|| { + toolpath::v1::Graph::from_json(&loaded.body) + .ok() + .map(std::borrow::Cow::Owned) + }) + .and_then(|doc| doc_session_dir(&doc)) + }); + let dest = resolve_destination(args, &auth, base_url, session_dir)?; + let upload = crate::cmd_export::PathbaseUploadArgs { + url: args.url.clone(), + anon: args.anon, + repo: dest.repo, + name: args.name.clone(), + public: args.public, + }; + crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &loaded.body, &summary) +} + +/// A session's document as it should be uploaded. +struct LoadedSession { + cache_id: String, + body: String, + /// The parsed document when this load derived it; `None` when the + /// body was read straight from the cache. + doc: Option, +} + +/// The current document for one session: read from the cache when the +/// manifest shows the source unchanged since it was written (a derive +/// would reproduce it byte-for-byte), otherwise derived — and, unless +/// `no_cache`, written to the cache so cache and upload agree. +fn load_session( + harness: ArtifactType, + project: Option<&str>, + session: &str, + no_cache: bool, +) -> Result { + if !no_cache && let Some(cache_id) = crate::sync::fresh_cache_id( &HarnessBundle::from_environment(), harness, - project.as_deref(), + project, session, ) { @@ -795,34 +894,18 @@ fn share_explicit( "Cache is current for {} session {cache_id}; uploading without re-deriving", harness.name() ); - let session_dir = project.as_deref().map(PathBuf::from).or_else(|| { - toolpath::v1::Graph::from_json(&body) - .ok() - .and_then(|doc| doc_session_dir(&doc)) + return Ok(LoadedSession { + cache_id, + body, + doc: None, }); - let dest = resolve_destination(args, &auth, base_url, session_dir)?; - let summary = format!("{} session {}", harness.name(), cache_id); - let upload = crate::cmd_export::PathbaseUploadArgs { - url: args.url.clone(), - anon: args.anon, - repo: dest.repo, - name: args.name.clone(), - public: args.public, - }; - return crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &body, &summary); } - let derived = derive_session(harness, project.as_deref(), session)?; - let summary = format!("{} session {}", harness.name(), derived.cache_id); - - if !args.no_cache { - // The cache entry should always reflect what was just uploaded. - // `path share` is "ship the current state of this session"; if - // the conversation has grown since a prior share, the in-memory - // body has the new turns but a stale cache file would not — and - // the upload uses the fresh body, not the cache. Always - // overwrite so cache and upload agree (use `--no-cache` to skip - // the cache write entirely). + let derived = derive_session(harness, project, session)?; + if !no_cache { + // Always overwrite: `share` ships the current state of the + // session, and a stale cache file from a prior share would + // otherwise disagree with what was uploaded. let path = crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; // Transitional: `share` does not take `&Config` yet; load one // for the engine. A load failure degrades like a manifest-write @@ -840,21 +923,323 @@ fn share_explicit( path.display() ); } - - let session_dir = project - .as_deref() - .map(PathBuf::from) - .or_else(|| doc_session_dir(&derived.doc)); - let dest = resolve_destination(args, &auth, base_url, session_dir)?; let body = derived.doc.to_json()?; - let upload = crate::cmd_export::PathbaseUploadArgs { - url: args.url.clone(), - anon: args.anon, - repo: dest.repo, - name: args.name.clone(), - public: args.public, + Ok(LoadedSession { + cache_id: derived.cache_id, + body, + doc: Some(derived.doc), + }) +} + +// ── `share --all` ─────────────────────────────────────────────────── + +/// Where one bulk-uploaded session goes. +#[derive(Debug, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)] +struct BulkTarget { + owner: String, + repo: String, + base_url: String, +} + +impl BulkTarget { + fn display(&self) -> String { + format!("{}/{}", self.owner, self.repo) + } + + fn url(&self) -> String { + format!("{}/u/{}/{}", self.base_url, self.owner, self.repo) + } +} + +/// One project directory's worth of sessions in the bulk summary. +#[derive(Debug, Clone, PartialEq, Eq)] +struct BulkGroup { + /// Session directory as reported by the providers; `None` for + /// sessions with no recorded cwd. + dir: Option, + /// Row indices into the gathered artifacts. + rows: Vec, + /// Per-harness counts, most first. + by_harness: Vec<(ArtifactType, usize)>, + /// A remote configured for this directory, when one resolved and + /// no `--repo` overrides it. + configured: Option, +} + +/// Group `rows` by session directory — the recorded cwd when known, +/// else the provider's project key — resolving each directory's +/// configured remote once through `remote`. Groups sort by descending +/// session count, then directory; the no-directory group sorts last. +fn plan_bulk( + rows: &[ArtifactRow], + mut remote: impl FnMut(&str) -> Result>, +) -> Result> { + use std::collections::BTreeMap; + let mut by_dir: BTreeMap, Vec> = BTreeMap::new(); + for (i, row) in rows.iter().enumerate() { + let dir = row.cwd.clone().or_else(|| row.path.clone()); + by_dir.entry(dir).or_default().push(i); + } + let mut groups = Vec::with_capacity(by_dir.len()); + for (dir, idxs) in by_dir { + let mut by_harness: Vec<(ArtifactType, usize)> = Vec::new(); + for &i in &idxs { + let t = rows[i].artifact_type; + match by_harness.iter_mut().find(|(h, _)| *h == t) { + Some((_, n)) => *n += 1, + None => by_harness.push((t, 1)), + } + } + by_harness.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name()))); + let configured = match &dir { + Some(d) => remote(d)?, + None => None, + }; + groups.push(BulkGroup { + dir, + rows: idxs, + by_harness, + configured, + }); + } + groups.sort_by(|a, b| { + b.rows + .len() + .cmp(&a.rows.len()) + .then_with(|| match (&a.dir, &b.dir) { + (Some(x), Some(y)) => x.cmp(y), + (Some(_), None) => std::cmp::Ordering::Less, + (None, Some(_)) => std::cmp::Ordering::Greater, + (None, None) => std::cmp::Ordering::Equal, + }) + }); + Ok(groups) +} + +/// The pre-upload summary: one line per project directory with its +/// session count, harness breakdown, and configured remote (if any), +/// followed by the fallback destination. +fn render_bulk_summary( + groups: &[BulkGroup], + total: usize, + project_under: Option<&std::path::Path>, + default_target: &BulkTarget, + home: Option<&std::path::Path>, +) -> String { + let mut out = match project_under { + Some(p) => format!( + "Found {total} sessions under {}\n", + crate::config::home_relative(p, home) + ), + None => format!("Found {total} sessions\n"), }; - crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &body, &summary) + let labels: Vec = groups + .iter() + .map(|g| match &g.dir { + Some(d) => crate::config::home_relative(std::path::Path::new(d), home), + None => "(no project)".to_string(), + }) + .collect(); + let label_width = labels.iter().map(|l| l.len()).max().unwrap_or(0); + let count_width = groups + .iter() + .map(|g| g.rows.len().to_string().len()) + .max() + .unwrap_or(1); + let breakdowns: Vec = groups + .iter() + .map(|g| { + g.by_harness + .iter() + .map(|(t, n)| format!("{} {n}", t.name())) + .collect::>() + .join(", ") + }) + .collect(); + let breakdown_width = breakdowns.iter().map(|b| b.len()).max().unwrap_or(0); + for ((g, label), breakdown) in groups.iter().zip(&labels).zip(&breakdowns) { + let mut line = format!( + " {label:count_width$} {breakdown:, + bundle: &HarnessBundle, + cwd: &std::path::Path, + auth: crate::cmd_pathbase::AuthMode, + base_url: String, +) -> Result<()> { + let crate::cmd_pathbase::AuthMode::Authed { token, username } = auth else { + anyhow::bail!("`share --all` requires login. Run `path auth login`."); + }; + + let scope = args.project_under.as_deref().map(ProjectScope::Under); + let rows = gather_artifacts(bundle, cwd, harness, scope.as_ref()); + if rows.is_empty() { + match args.project_under.as_deref() { + Some(p) => anyhow::bail!("No agent sessions found under {}.", p.display()), + None => return bail_no_sessions(bundle, None), + } + } + + let default_target = BulkTarget { + owner: args + .repo + .as_ref() + .map(|r| r.owner.clone()) + .unwrap_or_else(|| username.clone()), + repo: args + .repo + .as_ref() + .map(|r| r.name.clone()) + .unwrap_or_else(|| "pathstash".to_string()), + base_url: base_url.clone(), + }; + + let mut resolved: std::collections::HashMap> = Default::default(); + let groups = plan_bulk(&rows, |dir| { + if args.repo.is_some() { + return Ok(None); + } + if let Some(t) = resolved.get(dir) { + return Ok(t.clone()); + } + let target = crate::share_config::resolve_remote(std::path::Path::new(dir))?.map(|found| { + BulkTarget { + owner: found.repo.owner, + repo: found.repo.name, + base_url: match (&args.url, found.base_url) { + (None, Some(remote_url)) => remote_url, + _ => base_url.clone(), + }, + } + }); + resolved.insert(dir.to_string(), target.clone()); + Ok(target) + })?; + + let home = crate::config::home_dir(); + eprint!( + "{}", + render_bulk_summary( + &groups, + rows.len(), + args.project_under.as_deref(), + &default_target, + home.as_deref(), + ) + ); + if args.dry_run { + return Ok(()); + } + if !args.yes { + let answer = + crate::cmd_pathbase::prompt_line(&format!("Upload {} sessions? [y/N] ", rows.len()))?; + if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") { + eprintln!("Aborted."); + std::process::exit(130); + } + } + + // Ensure the pathstash repo exists once, not once per upload. A + // configured remote is expected to exist already. + if args.repo.is_none() { + crate::cmd_pathbase::repos_post(&base_url, &token, &username, "pathstash")?; + } + + let mut uploaded: std::collections::BTreeMap = Default::default(); + let mut failed = 0usize; + let total = rows.len(); + let mut n = 0usize; + for group in &groups { + let target = group.configured.as_ref().unwrap_or(&default_target); + for &i in &group.rows { + n += 1; + let row = &rows[i]; + let project = row + .artifact_type + .path_keyed() + .then(|| row.path.clone()) + .flatten(); + let label = format!("{} {}", row.artifact_type.name(), row.session_id); + eprintln!("[{n}/{total}] {label}"); + let result = load_session( + row.artifact_type, + project.as_deref(), + &row.session_id, + args.no_cache, + ) + .and_then(|loaded| { + let doc = toolpath::v1::Graph::from_json(&loaded.body) + .map_err(|e| anyhow::anyhow!("Invalid toolpath document: {e}"))?; + let name = args + .name + .clone() + .unwrap_or_else(|| crate::cmd_export::derive_name(&doc)); + crate::cmd_pathbase::graphs_post( + &target.base_url, + &token, + &target.owner, + &target.repo, + Some(&name), + &loaded.body, + args.public, + ) + }); + match result { + Ok(created) => { + eprintln!(" → {}", created.url); + *uploaded.entry(target.clone()).or_default() += 1; + } + Err(e) => { + eprintln!(" warning: {label} failed: {e:#}"); + failed += 1; + } + } + } + } + + let ok = total - failed; + if failed > 0 { + eprintln!("Uploaded {ok} of {total} sessions ({failed} failed)"); + } else { + eprintln!("Uploaded {ok} sessions"); + } + let width = uploaded + .keys() + .map(|t| t.display().len()) + .max() + .unwrap_or(0); + for (target, count) in &uploaded { + println!( + " {:4} {}", + target.display(), + target.url() + ); + } + if failed > 0 { + anyhow::bail!("{failed} of {total} uploads failed"); + } + Ok(()) } /// The directory a derived session document belongs to: its single @@ -1268,6 +1653,142 @@ mod tests { ); } + fn bulk_row(t: ArtifactType, dir: Option<&str>, session: &str) -> ArtifactRow { + let (path, cwd) = if t.path_keyed() { + (dir.map(str::to_string), None) + } else { + (None, dir.map(str::to_string)) + }; + ArtifactRow { + artifact_type: t, + path, + cwd, + session_id: session.to_string(), + title: "t".to_string(), + last_activity: None, + message_count: Some(1), + matches_cwd: false, + } + } + + fn target(owner: &str, repo: &str) -> BulkTarget { + BulkTarget { + owner: owner.to_string(), + repo: repo.to_string(), + base_url: "https://pb.test".to_string(), + } + } + + #[test] + fn gather_artifacts_project_under_admits_subtree() { + let temp = TempDir::new().unwrap(); + let claude = temp.path().join(".claude"); + write_claude_session(&claude, "-work-foo", "s1", "a"); + write_claude_session(&claude, "-work-foo-sub", "s2", "b"); + write_claude_session(&claude, "-other", "s3", "c"); + let bundle = claude_only_bundle(temp.path()); + let scope = ProjectScope::Under(Path::new("/work")); + let mut rows = gather_artifacts(&bundle, Path::new("/x"), None, Some(&scope)); + rows.sort_by(|a, b| a.session_id.cmp(&b.session_id)); + let ids: Vec<&str> = rows.iter().map(|r| r.session_id.as_str()).collect(); + assert_eq!(ids, ["s1", "s2"]); + } + + #[test] + fn gather_artifacts_claude_rows_carry_recorded_cwd() { + let temp = TempDir::new().unwrap(); + write_claude_session(&temp.path().join(".claude"), "-test-project", "s1", "a"); + let bundle = claude_only_bundle(temp.path()); + let rows = gather_artifacts(&bundle, Path::new("/x"), None, None); + assert_eq!(rows[0].cwd.as_deref(), Some("/test/project")); + } + + #[test] + fn plan_bulk_groups_by_recorded_cwd_over_project_key() { + let mut a = bulk_row(ArtifactType::Claude, Some("/w/my/app"), "a"); + a.cwd = Some("/w/my_app".to_string()); + let mut b = bulk_row(ArtifactType::Pi, Some("/w/my/app"), "b"); + b.cwd = Some("/w/my_app".to_string()); + let groups = plan_bulk(&[a, b], |_| Ok(None)).unwrap(); + assert_eq!(groups.len(), 1); + assert_eq!(groups[0].dir.as_deref(), Some("/w/my_app")); + } + + #[test] + fn plan_bulk_groups_by_dir_and_sorts_by_count() { + let rows = vec![ + bulk_row(ArtifactType::Codex, None, "n1"), + bulk_row(ArtifactType::Claude, Some("/w/bar"), "b1"), + bulk_row(ArtifactType::Claude, Some("/w/foo"), "f1"), + bulk_row(ArtifactType::Codex, Some("/w/foo"), "f2"), + bulk_row(ArtifactType::Claude, Some("/w/foo"), "f3"), + bulk_row(ArtifactType::Cursor, Some("/w/bar"), "b2"), + bulk_row(ArtifactType::Cursor, Some("/w/bar"), "b3"), + ]; + let mut asked = Vec::new(); + let groups = plan_bulk(&rows, |dir| { + asked.push(dir.to_string()); + Ok((dir == "/w/foo").then(|| target("me", "foo"))) + }) + .unwrap(); + + let dirs: Vec> = groups.iter().map(|g| g.dir.as_deref()).collect(); + assert_eq!(dirs, [Some("/w/bar"), Some("/w/foo"), None]); + assert_eq!(groups[0].rows.len(), 3); + assert_eq!( + groups[0].by_harness, + vec![(ArtifactType::Cursor, 2), (ArtifactType::Claude, 1)] + ); + assert_eq!( + groups[1].by_harness, + vec![(ArtifactType::Claude, 2), (ArtifactType::Codex, 1)] + ); + assert_eq!(groups[1].configured, Some(target("me", "foo"))); + assert_eq!(groups[0].configured, None); + assert_eq!(groups[2].configured, None); + assert_eq!(asked.len(), 2, "one resolve per directory, none for no-dir"); + } + + #[test] + fn render_bulk_summary_layout() { + let rows = vec![ + bulk_row(ArtifactType::Claude, Some("/home/me/work/foo"), "f1"), + bulk_row(ArtifactType::Codex, Some("/home/me/work/foo"), "f2"), + bulk_row(ArtifactType::Claude, Some("/home/me/work/bar"), "b1"), + bulk_row(ArtifactType::Cursor, None, "n1"), + ]; + let groups = plan_bulk(&rows, |dir| { + Ok((dir == "/home/me/work/foo").then(|| target("me", "foo"))) + }) + .unwrap(); + let out = render_bulk_summary( + &groups, + rows.len(), + Some(Path::new("/home/me/work")), + &target("me", "pathstash"), + Some(Path::new("/home/me")), + ); + assert_eq!( + out, + "Found 4 sessions under ~/work\n\ + \x20 ~/work/foo 2 claude 1, codex 1 (→ me/foo)\n\ + \x20 ~/work/bar 1 claude 1\n\ + \x20 (no project) 1 cursor 1\n\ + Destination: me/pathstash unless noted\n" + ); + } + + #[test] + fn render_bulk_summary_without_configured_remotes() { + let rows = vec![bulk_row(ArtifactType::Claude, Some("/p"), "s")]; + let groups = plan_bulk(&rows, |_| Ok(None)).unwrap(); + let out = render_bulk_summary(&groups, 1, None, &target("me", "pathstash"), None); + assert_eq!( + out, + "Found 1 sessions\n /p 1 claude 1\nDestination: me/pathstash\n" + ); + } + #[test] fn parse_picker_row_roundtrips_keyed() { let row = ArtifactRow { @@ -1408,6 +1929,10 @@ mod tests { session: None, project: None, no_cache: false, + all: false, + project_under: None, + dry_run: false, + yes: false, } } diff --git a/crates/path-cli/src/sync/sources.rs b/crates/path-cli/src/sync/sources.rs index d310249c..15b012db 100644 --- a/crates/path-cli/src/sync/sources.rs +++ b/crates/path-cli/src/sync/sources.rs @@ -93,6 +93,18 @@ fn canonicalize_or_self(p: &Path) -> PathBuf { /// Subtree check for a real filesystem path. Canonicalizes both /// sides, but also accepts the raw parent so a not-yet-resolvable /// constraint (or an unresolvable dir) still matches literally. +/// Whether `dir` (a project directory or recorded cwd as the provider +/// reports it) lies under `project_under`, using the same per-provider +/// comparison sync uses — slug space for claude and pi, whose on-disk +/// names are lossy. +pub(crate) fn project_in_scope(t: ArtifactType, dir: &str, project_under: &Path) -> bool { + match t { + ArtifactType::Claude => claude_project_in_scope(dir, project_under), + ArtifactType::Pi => pi_project_in_scope(dir, project_under), + _ => dir_in_scope(dir, project_under), + } +} + fn dir_in_scope(dir: &str, project_under: &Path) -> bool { let d = canonicalize_or_self(Path::new(dir)); d.starts_with(canonicalize_or_self(project_under)) || d.starts_with(project_under) diff --git a/crates/toolpath-claude/Cargo.toml b/crates/toolpath-claude/Cargo.toml index a0aaf7ab..e49e3345 100644 --- a/crates/toolpath-claude/Cargo.toml +++ b/crates/toolpath-claude/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-claude" -version = "0.13.0" +version = "0.13.1" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-claude/src/lib.rs b/crates/toolpath-claude/src/lib.rs index a3ae20d9..baeaffbf 100644 --- a/crates/toolpath-claude/src/lib.rs +++ b/crates/toolpath-claude/src/lib.rs @@ -186,6 +186,7 @@ impl ClaudeConvo { let mut last_activity = None; let mut file_path = std::path::PathBuf::new(); let mut first_user_message: Option = None; + let mut cwd: Option = None; for (i, segment_id) in chain.iter().enumerate() { let meta = self @@ -206,6 +207,9 @@ impl ClaudeConvo { if first_user_message.is_none() && meta.first_user_message.is_some() { first_user_message = meta.first_user_message; } + if cwd.is_none() { + cwd = meta.cwd; + } } Ok(ConversationMetadata { @@ -216,6 +220,7 @@ impl ClaudeConvo { started_at, last_activity, first_user_message, + cwd, }) } diff --git a/crates/toolpath-claude/src/reader.rs b/crates/toolpath-claude/src/reader.rs index 0fd75912..2a938f5c 100644 --- a/crates/toolpath-claude/src/reader.rs +++ b/crates/toolpath-claude/src/reader.rs @@ -88,6 +88,7 @@ impl ConversationReader { let mut started_at = None; let mut last_activity = None; let mut first_user_message: Option = None; + let mut cwd: Option = None; for line in reader.lines() { let line = line?; @@ -101,6 +102,9 @@ impl ConversationReader { if entry.message.is_some() { message_count += 1; } + if cwd.is_none() { + cwd = entry.cwd.clone().filter(|c| !c.is_empty()); + } // Skip tool-result-only user entries — `Message::text()` // collapses them to "". @@ -136,6 +140,7 @@ impl ConversationReader { started_at, last_activity, first_user_message, + cwd, }) } @@ -328,6 +333,7 @@ mod tests { assert_eq!(meta.message_count, 2); assert_eq!(meta.session_id, "session-1"); assert_eq!(meta.project_path, "/Users/alex/Devel/myproject"); + assert_eq!(meta.cwd.as_deref(), Some("/Users/ben/elsewhere")); assert!(meta.started_at.is_some()); assert!(meta.last_activity.is_some()); } diff --git a/crates/toolpath-claude/src/types.rs b/crates/toolpath-claude/src/types.rs index 88c92155..72bc7c53 100644 --- a/crates/toolpath-claude/src/types.rs +++ b/crates/toolpath-claude/src/types.rs @@ -542,6 +542,11 @@ pub struct ConversationMetadata { /// First non-empty user-prompt text. Used as a human-readable title. #[serde(default, skip_serializing_if = "Option::is_none")] pub first_user_message: Option, + /// The working directory the session recorded (`cwd` on its first + /// entry that carries one). Unlike `project_path`, which is decoded + /// from the lossy directory slug, this is the real path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, } #[cfg(test)] diff --git a/crates/toolpath-pi/Cargo.toml b/crates/toolpath-pi/Cargo.toml index 72b1e236..89ab3c48 100644 --- a/crates/toolpath-pi/Cargo.toml +++ b/crates/toolpath-pi/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-pi" -version = "0.6.1" +version = "0.6.2" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" diff --git a/crates/toolpath-pi/src/io.rs b/crates/toolpath-pi/src/io.rs index 16c619b0..bcec7e5c 100644 --- a/crates/toolpath-pi/src/io.rs +++ b/crates/toolpath-pi/src/io.rs @@ -69,9 +69,7 @@ pub fn list_sessions(resolver: &PathResolver, project: &str) -> Result Result { + let (id, timestamp, entry_count, cwd) = match parsed { + Some((id, ts, cwd)) => { let ec = total_nonempty.saturating_sub(1); - (id, ts, ec) + (id, ts, ec, cwd) } None => { let id = fallback_id_from_stem(stem); let ts = file_mtime_rfc3339(&path).unwrap_or_else(|| String::from("")); - (id, ts, total_nonempty) + (id, ts, total_nonempty, None) } }; @@ -118,6 +116,7 @@ pub fn list_sessions(resolver: &PathResolver, project: &str) -> Result std::io::Result> { } /// If the line is a `{"type":"session", ...}` header, return `(id, timestamp)`. -fn parse_header_id_and_timestamp(line: &str) -> Option<(String, String)> { +/// (id, timestamp, cwd) from a session header line; `None` when the +/// line is not a header or lacks id/timestamp. +fn parse_header(line: &str) -> Option<(String, String, Option)> { let v: serde_json::Value = serde_json::from_str(line).ok()?; let obj = v.as_object()?; if obj.get("type").and_then(|t| t.as_str()) != Some("session") { @@ -155,7 +156,12 @@ fn parse_header_id_and_timestamp(line: &str) -> Option<(String, String)> { } let id = obj.get("id")?.as_str()?.to_string(); let timestamp = obj.get("timestamp")?.as_str()?.to_string(); - Some((id, timestamp)) + let cwd = obj + .get("cwd") + .and_then(|c| c.as_str()) + .filter(|c| !c.is_empty()) + .map(str::to_string); + Some((id, timestamp, cwd)) } /// Strip a leading timestamp prefix from a filename stem. @@ -324,6 +330,7 @@ mod tests { assert_eq!(s.id, "s1"); assert_eq!(s.timestamp, "2026-04-16T10:00:00Z"); assert_eq!(s.entry_count, 2); + assert_eq!(s.cwd.as_deref(), Some("/p")); assert!(s.file_path.to_string_lossy().ends_with(".jsonl")); } diff --git a/crates/toolpath-pi/src/reader.rs b/crates/toolpath-pi/src/reader.rs index 3f6b0595..4bbf3a06 100644 --- a/crates/toolpath-pi/src/reader.rs +++ b/crates/toolpath-pi/src/reader.rs @@ -22,6 +22,10 @@ pub struct SessionMeta { /// human-readable title for picker UIs (e.g. `path list pi --format tsv` /// piped into fzf). `None` if the session has no parseable user message. pub first_user_message: Option, + /// The working directory from the session header. Unlike the + /// project string decoded from the session directory name, which + /// turns every `-` into `/`, this is the real path. + pub cwd: Option, } /// In-memory representation of a Pi session file (plus optional parent). diff --git a/site/_data/crates.json b/site/_data/crates.json index 489fdd57..062ba579 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -33,7 +33,7 @@ }, { "name": "toolpath-claude", - "version": "0.13.0", + "version": "0.13.1", "description": "Derive from Claude conversation logs", "docs": "https://docs.rs/toolpath-claude", "crate": "https://crates.io/crates/toolpath-claude", @@ -73,7 +73,7 @@ }, { "name": "toolpath-pi", - "version": "0.6.1", + "version": "0.6.2", "description": "Derive Toolpath provenance documents from Pi (pi.dev) agent session logs", "docs": "https://docs.rs/toolpath-pi", "crate": "https://crates.io/crates/toolpath-pi", @@ -113,7 +113,7 @@ }, { "name": "path-cli", - "version": "0.18.0", + "version": "0.19.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", From 2e93e38ae9f8f5d72a25f88b74ceca06d561c2ec Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Fri, 28 Aug 2026 10:24:24 -0400 Subject: [PATCH 2/7] record share uploads in the sync manifest; skip already-uploaded sessions Authed uploads from `share` (single and `--all`) are recorded on the artifact's manifest record as `uploads`: server, `owner/name`, graph id, URL, and the source fingerprint (mtime+size) at upload time, one entry per destination. Sync and import rewrite the fingerprint but carry the upload history across (`put_record`); anonymous uploads are not recorded since there is no repo to key on. `share --all` classifies each session against its destination and skips ones already uploaded. Sessions that changed since upload are skipped too: there is no graph update endpoint, so re-uploading would only create a duplicate. The summary heading reports both counts and the prompt shows the number that will actually go. Single-session `share` on an unchanged session prints the existing URL instead of uploading again; on a changed one it uploads a new graph and says so. `--force` ignores the record in both modes. `run_pathbase_inner` now returns what it uploaded so callers can record it. --- CHANGELOG.md | 13 +- CLAUDE.md | 4 +- crates/path-cli/src/cmd_export.rs | 22 +- crates/path-cli/src/cmd_share.rs | 472 +++++++++++++++++++++++++---- crates/path-cli/src/sync/engine.rs | 307 +++++++++++++++++-- 5 files changed, 729 insertions(+), 89 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9d215ad9..0f4013ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,8 +16,17 @@ All notable changes to the Toolpath workspace are documented here. `--repo` for everything, else each directory's configured remote, else `/pathstash`. Requires login; `--anon` is rejected. Uploads are sequential, failures warn and continue, and the run exits - non-zero if any failed. Nothing records what was already shared, so - re-running uploads everything again. + non-zero if any failed. +- **`path-cli`**: authed uploads from `share` are recorded in the sync + manifest (`uploads` on the artifact's record: server, repo, graph id, + URL, and the source fingerprint at upload time). `share --all` skips + sessions already uploaded to their destination and, since there is no + graph update endpoint, also skips ones that changed since — the + summary heading reports both counts. Single-session `share` on an + unchanged session prints the existing URL instead of uploading a + duplicate. `--force` uploads regardless. Sync and import rewrite the + fingerprint but keep the upload history; anonymous uploads are not + recorded. - **`toolpath-claude`** (0.13.1): `ConversationMetadata.cwd` — the working directory the session recorded, captured during the metadata pass. `project_path` is decoded from the directory slug, which is diff --git a/CLAUDE.md b/CLAUDE.md index f774ad30..613ea7e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,7 +217,7 @@ Format references for the agent on-disk formats live at `docs/agents/formats/` ### CLI behaviors - Interactive pickers: `p import ` auto-launches a fuzzy picker when TTY and no `--session`. Backend: external `fzf` if present, else the embedded skim picker (`embedded-picker` default feature, `crates/path-cli/src/skim_picker.rs`). Multi-select produces a `Graph`; single-select a `Path`. No usable backend falls back to most-recent (with `--project`) or prints the manual recipe. `p list --format tsv` is the machine-readable surface; the trailing column carries `first_user_message`. -- `path share` is the one-shot `p import | p export pathbase`: probes installed harnesses, aggregates sessions into one picker ranking current-directory sessions first; `--harness`/`--session`/`--project` skip the picker; pathbase flags match `p export pathbase`. When the sync manifest shows the picked session unchanged (`sync::fresh_cache_id`), it uploads the cached doc instead of re-deriving. Uploads carry the same full derivation as local projection — no egress stripping. `share --all` skips the picker and uploads every session in scope (`--project-under` subtree, `--harness`), printing a per-project summary and confirming first; it needs login and keeps no record of prior uploads. +- `path share` is the one-shot `p import | p export pathbase`: probes installed harnesses, aggregates sessions into one picker ranking current-directory sessions first; `--harness`/`--session`/`--project` skip the picker; pathbase flags match `p export pathbase`. When the sync manifest shows the picked session unchanged (`sync::fresh_cache_id`), it uploads the cached doc instead of re-deriving. Uploads carry the same full derivation as local projection — no egress stripping. `share --all` skips the picker and uploads every session in scope (`--project-under` subtree, `--harness`), printing a per-project summary and confirming first; it needs login. Authed uploads are recorded on the manifest record (`SyncRecord.uploads`: server, repo, graph id, URL, source stamp); `--all` skips sessions recorded as uploaded to that destination, unchanged or changed (no update endpoint exists, so a changed session would only duplicate), and single `share` short-circuits on an unchanged one. `--force` ignores the record. `put_record` in `sync/engine.rs` carries `uploads` across sync/import rewrites. - Share also resolves a **configured share remote** from the session's own directory (the recorded cwd wherever the provider reports one — claude/pi carry it on `ConversationMetadata.cwd`/`SessionMeta.cwd` because their slug-decoded project paths are lossy; else `--project`; else the doc's `path.base`): `crates/path-cli/src/share_config.rs` checks `~/.toolpath/config.toml` `[[project]]` rules (`dir` subtree match, `~/`-expandable, most specific wins; `remote` = bare `owner/name` or a canonical Pathbase repo web URL `https:///u//`, which also carries the server — the URL scheme is the extension point for future backends, unknown schemes rejected). Precedence: `--repo` flag > config > `/pathstash`; `--url` beats a URL remote's embedded server. A resolved remote prints `Sharing to ()`; hitting one while logged out errors with a `path auth login` hint (no silent anon fall-through), and explicit `--anon` opts out of the mapping. Both sides of the subtree match are prefix-canonicalized (longest existing ancestor resolved, tail re-appended) so macOS `/var`→`/private/var` and deleted checkouts still match. A repo-tracked `.toolpath.toml` is deliberately **not** consulted — a committed file redirecting other users' uploads needs a first-use consent flow (issue #179). - `path resume ` is the inverse: accepts a Pathbase URL, `owner/repo/slug` shorthand, local file, or cache id; validates a single agent-bearing `Path`; opens a harness picker (pre-selecting `path.meta.source` when installed; `--harness` skips); projects the session into the harness's on-disk layout under `-C/--cwd` (default: shell cwd) and `execvp`'s the harness's resume command (spawn-and-wait on Windows). - `path query` plans before it scans: `crates/path-cli/src/query/plan.rs` classifies the jaq filter into `PerFileStream` (element-wise, print as you go), `Decompose` (algebraic aggregation with a derived combine), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative so **the planner never changes an answer**; `query/filter.rs` tests assert streamed output equals slurp byte-for-byte. Execution is parallel (`mod.rs::execute_plan`/`for_each_file`): `PerFileStream`/`Decompose` run the whole per-file pipeline (parse → wrap → filter → render/pack) on rayon workers in chunks — partials cross threads as compact JSON bytes since jaq `Val`s are `Rc`-based — while `Slurp` parallelizes only parse/wrap; output stays byte-identical to a sequential scan (ordering, warnings, error precedence), and the emscripten build stays fully sequential. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan; there is no user-facing flag. Caveat: a streamed top-N matches slurp's ranking, but boundary ties may resolve to different rows. @@ -226,7 +226,7 @@ Format references for the agent on-disk formats live at `docs/agents/formats/` - `p cache sync [types…]` incrementally ingests artifacts into the cache. Engine in `crates/path-cli/src/sync/` (`engine.rs` loop + `SyncObserver`, `sources.rs` per-provider `ArtifactSource` impls); artifact model in `src/artifact.rs`; progress UI in `cmd_cache.rs`. - Change detection is **stat-level** (source mtime+size, or DB row updated-at) — a no-op sync reads no session bodies. Claude stamps the *whole session chain* (max segment mtime + summed sizes) because appends land in the newest segment, not the chain head. -- Manifest at `~/.toolpath/manifest.json`: artifact type → id → `{path?, cache_id, modified?, size?, synced_at}`. Atomic temp+rename writes, advisory lock with read-merge-save, checkpointed every 10 writes — concurrent invocations union their records, and an interrupted run keeps what it derived (derives run newest-first). +- Manifest at `~/.toolpath/manifest.json`: artifact type → id → `{path?, cache_id, modified?, size?, synced_at, uploads?}`. Atomic temp+rename writes, advisory lock with read-merge-save, checkpointed every 10 writes — concurrent invocations union their records, and an interrupted run keeps what it derived (derives run newest-first). - Sync writes with refresh semantics and never deletes: artifacts removed upstream keep their cache docs and manifest records (archive, not mirror). Derivation failures warn and tally, they don't abort. A record without `cache_id` is "known, not materialized" (scope-excluded, or downgraded by `p cache rm`); the next in-scope sync re-materializes it, and sync verifies doc files actually exist before skipping. - `--project-under ` (on both `p cache sync` and `path query`) restricts to sessions whose project directory is under that subtree. Claude compares in *slug space* (its dir slugs are lossy — `/`, `_`, `.` all became `-`); codex/copilot get a one-line cwd peek only when new/changed, memoized into the record. The stat gate always runs before any scope check. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's recorded cwd, not the lossy slug. - `path query` runs sync implicitly, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), degrading to the cache as-is if sync fails; `--no-sync` opts out. diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 2cf3a184..5c7fb691 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -1911,7 +1911,7 @@ fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { let needs_auth = upload.repo.is_some() || upload.public || upload.name.is_some(); let auth = preflight_auth(&base_url, upload.anon, needs_auth)?; let summary_source = file.display().to_string(); - run_pathbase_inner(auth, base_url, upload, &body, &summary_source) + run_pathbase_inner(auth, base_url, upload, &body, &summary_source).map(|_| ()) } } @@ -1933,6 +1933,16 @@ pub(crate) fn resolve_upload_base_url(args: &PathbaseUploadArgs) -> String { resolve_url(None) } +#[cfg(not(target_os = "emscripten"))] +/// An authed upload's result: where it landed and what the server +/// returned. `run_pathbase_inner` yields `None` for anonymous uploads. +#[cfg(not(target_os = "emscripten"))] +pub(crate) struct UploadedGraph { + pub(crate) owner: String, + pub(crate) repo: String, + pub(crate) created: crate::cmd_pathbase::CreatedGraph, +} + #[cfg(not(target_os = "emscripten"))] pub(crate) fn run_pathbase_inner( auth: crate::cmd_pathbase::AuthMode, @@ -1940,7 +1950,7 @@ pub(crate) fn run_pathbase_inner( args: PathbaseUploadArgs, body: &str, summary_source: &str, -) -> Result<()> { +) -> Result> { use crate::cmd_pathbase::{AuthMode, anon_graphs_post, graphs_post, repos_post}; use pathbase_client::types::Visibility; @@ -1969,7 +1979,7 @@ pub(crate) fn run_pathbase_inner( body.len() ); println!("{printable}"); - return Ok(()); + return Ok(None); } AuthMode::Authed { token, username } => (token, username), }; @@ -2021,7 +2031,11 @@ pub(crate) fn run_pathbase_inner( body.len() ); println!("{}", created.url); - Ok(()) + Ok(Some(UploadedGraph { + owner, + repo, + created, + })) } /// Default display label for a graph uploaded via `export pathbase`. diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 470a6108..ab40769e 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -75,6 +75,11 @@ pub struct ShareArgs { /// With --all: skip the confirmation prompt #[arg(long, short = 'y', requires = "all")] pub yes: bool, + + /// Upload even if the session was already uploaded to this + /// destination (recorded in the sync manifest) + #[arg(long)] + pub force: bool, } /// Which sessions `gather_artifacts` keeps, by project directory. @@ -543,7 +548,8 @@ pub fn run(args: ShareArgs) -> Result<()> { let bundle = HarnessBundle::from_environment(); if args.all { - let auth = crate::cmd_pathbase::preflight_auth(&base_url, false, true)?; + let auth = crate::cmd_pathbase::preflight_auth(&base_url, false, true) + .context("`share --all` requires an authenticated upload")?; return share_all(&args, harness, &bundle, &cwd, auth, base_url); } @@ -621,6 +627,7 @@ pub fn run(args: ShareArgs) -> Result<()> { project_under: None, dry_run: false, yes: false, + force: args.force, }; // Show the conversation title in the confirmation line; the session id // is opaque and doesn't help the user verify they picked the right @@ -850,6 +857,39 @@ fn share_explicit( .and_then(|doc| doc_session_dir(&doc)) }); let dest = resolve_destination(args, &auth, base_url, session_dir)?; + + let stamp = source_stamp( + &HarnessBundle::from_environment(), + harness, + project.as_deref(), + session, + ); + if let crate::cmd_pathbase::AuthMode::Authed { username, .. } = &auth { + let repo = dest + .repo + .as_ref() + .map(|r| format!("{}/{}", r.owner, r.name)) + .unwrap_or_else(|| format!("{username}/pathstash")); + match upload_class(harness, session, &dest.base_url, &repo, stamp) { + UploadClass::Uploaded(url) if !args.force => { + eprintln!( + "Already uploaded to {repo}, unchanged since; pass --force to upload again" + ); + println!("{url}"); + return Ok(()); + } + UploadClass::Uploaded(url) => { + eprintln!("Already uploaded to {repo} ({url}); uploading again"); + } + UploadClass::Changed(url) => { + eprintln!( + "Previously uploaded to {repo} ({url}); session changed since, uploading a new graph" + ); + } + UploadClass::New => {} + } + } + let upload = crate::cmd_export::PathbaseUploadArgs { url: args.url.clone(), anon: args.anon, @@ -857,7 +897,109 @@ fn share_explicit( name: args.name.clone(), public: args.public, }; - crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &loaded.body, &summary) + let base_url = dest.base_url.clone(); + let done = + crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &loaded.body, &summary)?; + if let Some(done) = done { + record_upload( + harness, + session, + project.as_deref(), + &base_url, + &format!("{}/{}", done.owner, done.repo), + &done.created, + stamp, + ); + } + Ok(()) +} + +/// The provider's project key for a row — what derive and stamp take +/// as `project`; `None` for session-keyed harnesses. +fn row_project(row: &ArtifactRow) -> Option<&str> { + if row.artifact_type.path_keyed() { + row.path.as_deref() + } else { + None + } +} + +/// The manifest's view of one session relative to one destination. +#[derive(Debug, Clone, PartialEq, Eq)] +enum UploadClass { + New, + /// Uploaded and unchanged since; carries the graph URL. + Uploaded(String), + /// Uploaded but the source changed since; carries the graph URL. + Changed(String), +} + +/// Current source fingerprint for a session; `(None, None)` when the +/// provider can't stat it, which reads as "changed". +fn source_stamp( + bundle: &HarnessBundle, + harness: ArtifactType, + project: Option<&str>, + session: &str, +) -> crate::sync::sources::Stamp { + crate::sync::sources::source_for(bundle, harness) + .and_then(|s| s.stamp(project, session)) + .unwrap_or((None, None)) +} + +fn upload_class( + harness: ArtifactType, + session: &str, + server: &str, + repo: &str, + stamp: crate::sync::sources::Stamp, +) -> UploadClass { + let manifest = crate::config::config_dir() + .and_then(|dir| crate::sync::load_manifest(&dir)) + .unwrap_or_default(); + classify(&manifest, harness, session, server, repo, stamp) +} + +fn classify( + manifest: &crate::sync::Manifest, + harness: ArtifactType, + session: &str, + server: &str, + repo: &str, + stamp: crate::sync::sources::Stamp, +) -> UploadClass { + match crate::sync::upload_state(manifest, harness, session, server, repo, stamp) { + crate::sync::UploadState::New => UploadClass::New, + crate::sync::UploadState::Uploaded(u) => UploadClass::Uploaded(u.url.clone()), + crate::sync::UploadState::Changed(u) => UploadClass::Changed(u.url.clone()), + } +} + +/// Remember a successful authed upload in the manifest. A failure +/// here only costs a future skip, so it warns instead of erroring. +fn record_upload( + harness: ArtifactType, + session: &str, + project: Option<&str>, + server: &str, + repo: &str, + created: &crate::cmd_pathbase::CreatedGraph, + stamp: crate::sync::sources::Stamp, +) { + let record = crate::sync::UploadRecord { + server: server.trim_end_matches('/').to_string(), + repo: repo.to_string(), + graph_id: created.id.clone(), + url: created.url.clone(), + modified: stamp.0, + size: stamp.1, + uploaded_at: Utc::now(), + }; + if let Err(e) = crate::config::config_dir() + .and_then(|dir| crate::sync::record_upload(&dir, harness, session, project, record)) + { + eprintln!("warning: upload not recorded in sync manifest: {e}"); + } } /// A session's document as it should be uploaded. @@ -959,6 +1101,8 @@ struct BulkGroup { dir: Option, /// Row indices into the gathered artifacts. rows: Vec, + /// Manifest classification per row, parallel to `rows`. + states: Vec, /// Per-harness counts, most first. by_harness: Vec<(ArtifactType, usize)>, /// A remote configured for this directory, when one resolved and @@ -968,11 +1112,14 @@ struct BulkGroup { /// Group `rows` by session directory — the recorded cwd when known, /// else the provider's project key — resolving each directory's -/// configured remote once through `remote`. Groups sort by descending +/// configured remote once through `remote` and classifying each row +/// against its destination through `state`. Groups sort by descending /// session count, then directory; the no-directory group sorts last. fn plan_bulk( rows: &[ArtifactRow], + default_target: &BulkTarget, mut remote: impl FnMut(&str) -> Result>, + mut state: impl FnMut(&ArtifactRow, &BulkTarget) -> UploadClass, ) -> Result> { use std::collections::BTreeMap; let mut by_dir: BTreeMap, Vec> = BTreeMap::new(); @@ -995,9 +1142,12 @@ fn plan_bulk( Some(d) => remote(d)?, None => None, }; + let target = configured.as_ref().unwrap_or(default_target); + let states = idxs.iter().map(|&i| state(&rows[i], target)).collect(); groups.push(BulkGroup { dir, rows: idxs, + states, by_harness, configured, }); @@ -1028,11 +1178,33 @@ fn render_bulk_summary( ) -> String { let mut out = match project_under { Some(p) => format!( - "Found {total} sessions under {}\n", + "Found {total} sessions under {}", crate::config::home_relative(p, home) ), - None => format!("Found {total} sessions\n"), + None => format!("Found {total} sessions"), }; + let (mut uploaded, mut changed, mut new) = (0, 0, 0); + for g in groups { + for st in &g.states { + match st { + UploadClass::Uploaded(_) => uploaded += 1, + UploadClass::Changed(_) => changed += 1, + UploadClass::New => new += 1, + } + } + } + if uploaded + changed > 0 { + let mut parts = Vec::new(); + if uploaded > 0 { + parts.push(format!("{uploaded} already uploaded")); + } + if changed > 0 { + parts.push(format!("{changed} changed since upload")); + } + parts.push(format!("{new} new")); + out.push_str(&format!(" ({})", parts.join(", "))); + } + out.push('\n'); let labels: Vec = groups .iter() .map(|g| match &g.dir { @@ -1115,27 +1287,55 @@ fn share_all( base_url: base_url.clone(), }; + let manifest = if args.force { + crate::sync::Manifest::default() + } else { + crate::sync::load_manifest(&crate::config::config_dir()?)? + }; let mut resolved: std::collections::HashMap> = Default::default(); - let groups = plan_bulk(&rows, |dir| { - if args.repo.is_some() { - return Ok(None); - } - if let Some(t) = resolved.get(dir) { - return Ok(t.clone()); - } - let target = crate::share_config::resolve_remote(std::path::Path::new(dir))?.map(|found| { - BulkTarget { - owner: found.repo.owner, - repo: found.repo.name, - base_url: match (&args.url, found.base_url) { - (None, Some(remote_url)) => remote_url, - _ => base_url.clone(), - }, + let groups = plan_bulk( + &rows, + &default_target, + |dir| { + if args.repo.is_some() { + return Ok(None); } - }); - resolved.insert(dir.to_string(), target.clone()); - Ok(target) - })?; + if let Some(t) = resolved.get(dir) { + return Ok(t.clone()); + } + let target = + crate::share_config::resolve_remote(std::path::Path::new(dir))?.map(|found| { + BulkTarget { + owner: found.repo.owner, + repo: found.repo.name, + base_url: match (&args.url, found.base_url) { + (None, Some(remote_url)) => remote_url, + _ => base_url.clone(), + }, + } + }); + resolved.insert(dir.to_string(), target.clone()); + Ok(target) + }, + |row, target| { + if args.force { + return UploadClass::New; + } + let stamp = source_stamp(bundle, row.artifact_type, row_project(row), &row.session_id); + classify( + &manifest, + row.artifact_type, + &row.session_id, + &target.base_url, + &target.display(), + stamp, + ) + }, + )?; + let to_upload: usize = groups + .iter() + .map(|g| g.states.iter().filter(|s| **s == UploadClass::New).count()) + .sum(); let home = crate::config::home_dir(); eprint!( @@ -1148,12 +1348,16 @@ fn share_all( home.as_deref(), ) ); + if to_upload == 0 { + eprintln!("Nothing to upload; pass --force to upload everything again."); + return Ok(()); + } if args.dry_run { return Ok(()); } if !args.yes { let answer = - crate::cmd_pathbase::prompt_line(&format!("Upload {} sessions? [y/N] ", rows.len()))?; + crate::cmd_pathbase::prompt_line(&format!("Upload {to_upload} sessions? [y/N] "))?; if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") { eprintln!("Aborted."); std::process::exit(130); @@ -1168,46 +1372,50 @@ fn share_all( let mut uploaded: std::collections::BTreeMap = Default::default(); let mut failed = 0usize; - let total = rows.len(); + let total = to_upload; let mut n = 0usize; for group in &groups { let target = group.configured.as_ref().unwrap_or(&default_target); - for &i in &group.rows { + for (&i, state) in group.rows.iter().zip(&group.states) { + if *state != UploadClass::New { + continue; + } n += 1; let row = &rows[i]; - let project = row - .artifact_type - .path_keyed() - .then(|| row.path.clone()) - .flatten(); + let project = row_project(row); let label = format!("{} {}", row.artifact_type.name(), row.session_id); eprintln!("[{n}/{total}] {label}"); - let result = load_session( - row.artifact_type, - project.as_deref(), - &row.session_id, - args.no_cache, - ) - .and_then(|loaded| { - let doc = toolpath::v1::Graph::from_json(&loaded.body) - .map_err(|e| anyhow::anyhow!("Invalid toolpath document: {e}"))?; - let name = args - .name - .clone() - .unwrap_or_else(|| crate::cmd_export::derive_name(&doc)); - crate::cmd_pathbase::graphs_post( - &target.base_url, - &token, - &target.owner, - &target.repo, - Some(&name), - &loaded.body, - args.public, - ) - }); + let stamp = source_stamp(bundle, row.artifact_type, project, &row.session_id); + let result = load_session(row.artifact_type, project, &row.session_id, args.no_cache) + .and_then(|loaded| { + let doc = toolpath::v1::Graph::from_json(&loaded.body) + .map_err(|e| anyhow::anyhow!("Invalid toolpath document: {e}"))?; + let name = args + .name + .clone() + .unwrap_or_else(|| crate::cmd_export::derive_name(&doc)); + crate::cmd_pathbase::graphs_post( + &target.base_url, + &token, + &target.owner, + &target.repo, + Some(&name), + &loaded.body, + args.public, + ) + }); match result { Ok(created) => { eprintln!(" → {}", created.url); + record_upload( + row.artifact_type, + &row.session_id, + project, + &target.base_url, + &target.display(), + &created, + stamp, + ); *uploaded.entry(target.clone()).or_default() += 1; } Err(e) => { @@ -1709,7 +1917,13 @@ mod tests { a.cwd = Some("/w/my_app".to_string()); let mut b = bulk_row(ArtifactType::Pi, Some("/w/my/app"), "b"); b.cwd = Some("/w/my_app".to_string()); - let groups = plan_bulk(&[a, b], |_| Ok(None)).unwrap(); + let groups = plan_bulk( + &[a, b], + &target("me", "pathstash"), + |_| Ok(None), + |_, _| UploadClass::New, + ) + .unwrap(); assert_eq!(groups.len(), 1); assert_eq!(groups[0].dir.as_deref(), Some("/w/my_app")); } @@ -1726,10 +1940,19 @@ mod tests { bulk_row(ArtifactType::Cursor, Some("/w/bar"), "b3"), ]; let mut asked = Vec::new(); - let groups = plan_bulk(&rows, |dir| { - asked.push(dir.to_string()); - Ok((dir == "/w/foo").then(|| target("me", "foo"))) - }) + let mut classified = Vec::new(); + let groups = plan_bulk( + &rows, + &target("me", "pathstash"), + |dir| { + asked.push(dir.to_string()); + Ok((dir == "/w/foo").then(|| target("me", "foo"))) + }, + |row, t| { + classified.push((row.session_id.clone(), t.display())); + UploadClass::New + }, + ) .unwrap(); let dirs: Vec> = groups.iter().map(|g| g.dir.as_deref()).collect(); @@ -1747,6 +1970,20 @@ mod tests { assert_eq!(groups[0].configured, None); assert_eq!(groups[2].configured, None); assert_eq!(asked.len(), 2, "one resolve per directory, none for no-dir"); + classified.sort(); + assert_eq!( + classified, + vec![ + ("b1".to_string(), "me/pathstash".to_string()), + ("b2".to_string(), "me/pathstash".to_string()), + ("b3".to_string(), "me/pathstash".to_string()), + ("f1".to_string(), "me/foo".to_string()), + ("f2".to_string(), "me/foo".to_string()), + ("f3".to_string(), "me/foo".to_string()), + ("n1".to_string(), "me/pathstash".to_string()), + ], + "each row is classified against its own destination" + ); } #[test] @@ -1757,9 +1994,12 @@ mod tests { bulk_row(ArtifactType::Claude, Some("/home/me/work/bar"), "b1"), bulk_row(ArtifactType::Cursor, None, "n1"), ]; - let groups = plan_bulk(&rows, |dir| { - Ok((dir == "/home/me/work/foo").then(|| target("me", "foo"))) - }) + let groups = plan_bulk( + &rows, + &target("me", "pathstash"), + |dir| Ok((dir == "/home/me/work/foo").then(|| target("me", "foo"))), + |_, _| UploadClass::New, + ) .unwrap(); let out = render_bulk_summary( &groups, @@ -1781,7 +2021,13 @@ mod tests { #[test] fn render_bulk_summary_without_configured_remotes() { let rows = vec![bulk_row(ArtifactType::Claude, Some("/p"), "s")]; - let groups = plan_bulk(&rows, |_| Ok(None)).unwrap(); + let groups = plan_bulk( + &rows, + &target("me", "pathstash"), + |_| Ok(None), + |_, _| UploadClass::New, + ) + .unwrap(); let out = render_bulk_summary(&groups, 1, None, &target("me", "pathstash"), None); assert_eq!( out, @@ -1789,6 +2035,103 @@ mod tests { ); } + #[test] + fn render_bulk_summary_reports_upload_classes() { + let rows = vec![ + bulk_row(ArtifactType::Claude, Some("/p"), "a"), + bulk_row(ArtifactType::Claude, Some("/p"), "b"), + bulk_row(ArtifactType::Claude, Some("/p"), "c"), + bulk_row(ArtifactType::Claude, Some("/p"), "d"), + ]; + let groups = plan_bulk( + &rows, + &target("me", "pathstash"), + |_| Ok(None), + |row, _| match row.session_id.as_str() { + "a" | "b" => UploadClass::Uploaded("u".into()), + "c" => UploadClass::Changed("u".into()), + _ => UploadClass::New, + }, + ) + .unwrap(); + let out = render_bulk_summary(&groups, 4, None, &target("me", "pathstash"), None); + assert!( + out.starts_with( + "Found 4 sessions (2 already uploaded, 1 changed since upload, 1 new)\n" + ), + "{out}" + ); + assert_eq!( + groups[0] + .states + .iter() + .filter(|s| **s == UploadClass::New) + .count(), + 1 + ); + } + + #[test] + fn classify_reads_manifest_uploads() { + use crate::sync::{Manifest, SyncRecord, UploadRecord}; + let stamp = (Some("2026-01-01T00:00:00Z".parse().unwrap()), Some(10u64)); + let upload = UploadRecord { + server: "https://pb.test".into(), + repo: "me/foo".into(), + graph_id: "g1".into(), + url: "https://pb.test/u/me/foo/graphs/g1".into(), + modified: stamp.0, + size: stamp.1, + uploaded_at: "2026-01-02T00:00:00Z".parse().unwrap(), + }; + let mut manifest = Manifest::default(); + manifest.entry("claude".into()).or_default().insert( + "s1".into(), + SyncRecord { + path: None, + cache_id: None, + modified: None, + size: None, + synced_at: "2026-01-02T00:00:00Z".parse().unwrap(), + uploads: vec![upload.clone()], + }, + ); + let c = |repo: &str, st| { + classify( + &manifest, + ArtifactType::Claude, + "s1", + "https://pb.test/", + repo, + st, + ) + }; + assert_eq!( + c("me/foo", stamp), + UploadClass::Uploaded(upload.url.clone()) + ); + assert_eq!( + c("me/foo", (stamp.0, Some(11))), + UploadClass::Changed(upload.url.clone()) + ); + assert_eq!( + c("me/foo", (None, None)), + UploadClass::Changed(upload.url.clone()) + ); + assert_eq!(c("me/bar", stamp), UploadClass::New); + assert_eq!( + classify( + &manifest, + ArtifactType::Codex, + "s1", + "https://pb.test", + "me/foo", + stamp + ), + UploadClass::New + ); + } + #[test] fn parse_picker_row_roundtrips_keyed() { let row = ArtifactRow { @@ -1933,6 +2276,7 @@ mod tests { project_under: None, dry_run: false, yes: false, + force: false, } } diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index 29e2a76c..fa2bf3d9 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -44,6 +44,111 @@ pub(crate) struct SyncRecord { #[serde(default, skip_serializing_if = "Option::is_none")] pub(crate) size: Option, pub(crate) synced_at: DateTime, + /// Pathbase uploads of this artifact, one per server+repo. Only + /// authed uploads are recorded — anonymous ones have no repo to + /// key on and cannot be listed later. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub(crate) uploads: Vec, +} + +/// One upload of an artifact to a Pathbase repo, with the source +/// fingerprint at upload time so a later run can tell "unchanged +/// since" from "changed since". +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +pub(crate) struct UploadRecord { + /// Server base URL as used for the upload. + pub(crate) server: String, + /// `owner/name`. + pub(crate) repo: String, + pub(crate) graph_id: String, + pub(crate) url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) modified: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub(crate) size: Option, + pub(crate) uploaded_at: DateTime, +} + +impl UploadRecord { + fn same_destination(&self, server: &str, repo: &str) -> bool { + crate::cmd_pathbase::host_of(&self.server) == crate::cmd_pathbase::host_of(server) + && self.repo == repo + } +} + +/// What the manifest knows about an artifact relative to one upload +/// destination. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) enum UploadState<'a> { + /// No upload recorded for this destination. + New, + /// Uploaded, and the source fingerprint still matches. + Uploaded(&'a UploadRecord), + /// Uploaded, but the source has changed since (or freshness is + /// unknowable — no stamp on either side). + Changed(&'a UploadRecord), +} + +/// The artifact's upload state for `server`/`repo`, judged against the +/// current source stamp. Only a real, matching stamp can vouch for +/// "unchanged"; a `None` stamp on either side reads as changed. +pub(crate) fn upload_state<'a>( + manifest: &'a Manifest, + artifact_type: ArtifactType, + id: &str, + server: &str, + repo: &str, + current: sources::Stamp, +) -> UploadState<'a> { + let Some(rec) = manifest + .get(artifact_type.name()) + .and_then(|records| records.get(id)) + .and_then(|rec| { + rec.uploads + .iter() + .find(|u| u.same_destination(server, repo)) + }) + else { + return UploadState::New; + }; + let (modified, size) = current; + if (rec.modified.is_some() || rec.size.is_some()) + && rec.modified == modified + && rec.size == size + { + UploadState::Uploaded(rec) + } else { + UploadState::Changed(rec) + } +} + +/// Record a successful upload. Replaces any prior record for the same +/// server+repo. Creates a known-but-uncached record when the artifact +/// has none yet (e.g. a `--no-cache` share). +pub(crate) fn record_upload( + config_dir: &Path, + artifact_type: ArtifactType, + id: &str, + path: Option<&str>, + upload: UploadRecord, +) -> Result<()> { + update_manifest(config_dir, |manifest| { + let rec = manifest + .entry(artifact_type.name().to_string()) + .or_default() + .entry(id.to_string()) + .or_insert_with(|| SyncRecord { + path: path.map(str::to_string), + cache_id: None, + modified: None, + size: None, + synced_at: Utc::now(), + uploads: Vec::new(), + }); + rec.uploads + .retain(|u| !u.same_destination(&upload.server, &upload.repo)); + rec.uploads.push(upload); + }) } /// The sync manifest: artifact type (`"claude"`, `"codex"`, …) → @@ -153,6 +258,17 @@ fn newest_first(artifacts: &[ArtifactRef]) -> Vec<&ArtifactRef> { order } +/// Replace `id`'s record, carrying over its upload history — sync and +/// import rewrite the source fingerprint, never what was uploaded. +fn put_record(records: &mut BTreeMap, id: String, mut rec: SyncRecord) { + if rec.uploads.is_empty() + && let Some(prev) = records.get_mut(&id) + { + rec.uploads = std::mem::take(&mut prev.uploads); + } + records.insert(id, rec); +} + /// Merge staged records into the manifest under the lock and clear /// the stage. fn flush_writes( @@ -165,10 +281,10 @@ fn flush_writes( let batch = std::mem::take(pending); update_manifest(config_dir, move |manifest| { for (name, records) in batch { - manifest - .entry(name.to_string()) - .or_default() - .extend(records); + let target = manifest.entry(name.to_string()).or_default(); + for (id, rec) in records { + put_record(target, id, rec); + } } }) } @@ -241,6 +357,7 @@ fn sync_artifacts( modified: artifact.modified, size: artifact.size, synced_at: Utc::now(), + uploads: Vec::new(), }, ); unflushed += 1; @@ -276,6 +393,7 @@ fn sync_artifacts( modified: artifact.modified, size: artifact.size, synced_at: Utc::now(), + uploads: Vec::new(), }, ); unflushed += 1; @@ -310,19 +428,20 @@ pub(crate) fn record_artifact( ) -> Result<()> { let config_dir = config.config_dir()?; update_manifest(&config_dir, |manifest| { - manifest - .entry(artifact.artifact_type.name().to_string()) - .or_default() - .insert( - artifact.id.clone(), - SyncRecord { - path: artifact.path.clone(), - cache_id: Some(cache_id.to_string()), - modified: artifact.modified, - size: artifact.size, - synced_at: Utc::now(), - }, - ); + put_record( + manifest + .entry(artifact.artifact_type.name().to_string()) + .or_default(), + artifact.id.clone(), + SyncRecord { + path: artifact.path.clone(), + cache_id: Some(cache_id.to_string()), + modified: artifact.modified, + size: artifact.size, + synced_at: Utc::now(), + uploads: Vec::new(), + }, + ); }) } @@ -497,6 +616,159 @@ mod tests { result } + fn upload(server: &str, repo: &str, graph_id: &str) -> UploadRecord { + UploadRecord { + server: server.to_string(), + repo: repo.to_string(), + graph_id: graph_id.to_string(), + url: format!("{server}/u/{repo}/graphs/{graph_id}"), + modified: Some("2026-01-01T00:00:00Z".parse().unwrap()), + size: Some(10), + uploaded_at: "2026-01-02T00:00:00Z".parse().unwrap(), + } + } + + #[test] + fn record_upload_creates_and_replaces_per_destination() { + with_cfg(|_, cfg| { + record_upload( + cfg, + ArtifactType::Claude, + "s1", + Some("/p"), + upload("https://a", "me/x", "g1"), + ) + .unwrap(); + record_upload( + cfg, + ArtifactType::Claude, + "s1", + None, + upload("https://a", "me/y", "g2"), + ) + .unwrap(); + // Same host, trailing slash: replaces the me/x record. + record_upload( + cfg, + ArtifactType::Claude, + "s1", + None, + upload("https://a/", "me/x", "g3"), + ) + .unwrap(); + let m = load_manifest(cfg).unwrap(); + let rec = &m["claude"]["s1"]; + assert_eq!(rec.path.as_deref(), Some("/p")); + assert_eq!(rec.cache_id, None, "known but not materialized"); + let ids: Vec<&str> = rec.uploads.iter().map(|u| u.graph_id.as_str()).collect(); + assert_eq!(ids, ["g2", "g3"]); + }); + } + + #[test] + fn sync_and_import_writes_keep_upload_history() { + with_cfg(|_, cfg| { + record_upload( + cfg, + ArtifactType::Claude, + "s1", + None, + upload("https://a", "me/x", "g1"), + ) + .unwrap(); + let artifact = ArtifactRef { + artifact_type: ArtifactType::Claude, + id: "s1".into(), + path: Some("/p".into()), + modified: Some("2026-03-01T00:00:00Z".parse().unwrap()), + size: Some(99), + }; + let config = Config::load().unwrap(); + record_artifact(&config, &artifact, "claude-s1").unwrap(); + let m = load_manifest(cfg).unwrap(); + let rec = &m["claude"]["s1"]; + assert_eq!(rec.cache_id.as_deref(), Some("claude-s1")); + assert_eq!(rec.size, Some(99)); + assert_eq!( + rec.uploads.len(), + 1, + "record_artifact must not drop uploads" + ); + + let mut pending = BTreeMap::new(); + pending + .entry("claude") + .or_insert_with(BTreeMap::new) + .insert( + "s1".to_string(), + SyncRecord { + path: Some("/p".into()), + cache_id: Some("claude-s1".into()), + modified: None, + size: Some(100), + synced_at: Utc::now(), + uploads: Vec::new(), + }, + ); + flush_writes(cfg, &mut pending).unwrap(); + let m = load_manifest(cfg).unwrap(); + let rec = &m["claude"]["s1"]; + assert_eq!(rec.size, Some(100)); + assert_eq!(rec.uploads.len(), 1, "flush_writes must not drop uploads"); + }); + } + + #[test] + fn upload_state_judges_by_stamp_and_destination() { + let mut m = Manifest::default(); + m.entry("claude".into()).or_default().insert( + "s1".into(), + SyncRecord { + path: None, + cache_id: None, + modified: None, + size: None, + synced_at: Utc::now(), + uploads: vec![upload("https://a", "me/x", "g1")], + }, + ); + let same = (Some("2026-01-01T00:00:00Z".parse().unwrap()), Some(10)); + assert!(matches!( + upload_state(&m, ArtifactType::Claude, "s1", "https://a", "me/x", same), + UploadState::Uploaded(u) if u.graph_id == "g1" + )); + assert!(matches!( + upload_state( + &m, + ArtifactType::Claude, + "s1", + "https://a", + "me/x", + (same.0, Some(11)) + ), + UploadState::Changed(_) + )); + assert!(matches!( + upload_state( + &m, + ArtifactType::Claude, + "s1", + "https://a", + "me/x", + (None, None) + ), + UploadState::Changed(_) + )); + assert_eq!( + upload_state(&m, ArtifactType::Claude, "s1", "https://b", "me/x", same), + UploadState::New + ); + assert_eq!( + upload_state(&m, ArtifactType::Claude, "s2", "https://a", "me/x", same), + UploadState::New + ); + } + fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) { let project_dir = home.join(".claude/projects").join(project_slug); std::fs::create_dir_all(&project_dir).unwrap(); @@ -552,6 +824,7 @@ mod tests { modified: Some("2024-01-02T00:00:01.123456789Z".parse().unwrap()), size: Some(4096), synced_at: "2026-07-09T00:00:00Z".parse().unwrap(), + uploads: Vec::new(), }, ); save_manifest(config_dir, &manifest).unwrap(); From e442f7d197c89705923bf95a60a2e3fe4aa0c9fe Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Fri, 28 Aug 2026 10:30:15 -0400 Subject: [PATCH 3/7] key upload records on the graph URL instead of storing server and repo The URL the server returns is /u///graphs/, so it already names the destination. `in_repo` matches a record to a repo by host and the `/graphs/` path prefix; a parent path such as `/u/` does not match. --- CHANGELOG.md | 5 +- CLAUDE.md | 2 +- crates/path-cli/src/cmd_share.rs | 73 +++++++----------- crates/path-cli/src/sync/engine.rs | 117 ++++++++++++----------------- 4 files changed, 76 insertions(+), 121 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f4013ce..58a29f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,8 +18,9 @@ All notable changes to the Toolpath workspace are documented here. are sequential, failures warn and continue, and the run exits non-zero if any failed. - **`path-cli`**: authed uploads from `share` are recorded in the sync - manifest (`uploads` on the artifact's record: server, repo, graph id, - URL, and the source fingerprint at upload time). `share --all` skips + manifest (`uploads` on the artifact's record: graph id, URL — which + names the server and repo — and the source fingerprint at upload + time). `share --all` skips sessions already uploaded to their destination and, since there is no graph update endpoint, also skips ones that changed since — the summary heading reports both counts. Single-session `share` on an diff --git a/CLAUDE.md b/CLAUDE.md index 613ea7e2..c43430a7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -217,7 +217,7 @@ Format references for the agent on-disk formats live at `docs/agents/formats/` ### CLI behaviors - Interactive pickers: `p import ` auto-launches a fuzzy picker when TTY and no `--session`. Backend: external `fzf` if present, else the embedded skim picker (`embedded-picker` default feature, `crates/path-cli/src/skim_picker.rs`). Multi-select produces a `Graph`; single-select a `Path`. No usable backend falls back to most-recent (with `--project`) or prints the manual recipe. `p list --format tsv` is the machine-readable surface; the trailing column carries `first_user_message`. -- `path share` is the one-shot `p import | p export pathbase`: probes installed harnesses, aggregates sessions into one picker ranking current-directory sessions first; `--harness`/`--session`/`--project` skip the picker; pathbase flags match `p export pathbase`. When the sync manifest shows the picked session unchanged (`sync::fresh_cache_id`), it uploads the cached doc instead of re-deriving. Uploads carry the same full derivation as local projection — no egress stripping. `share --all` skips the picker and uploads every session in scope (`--project-under` subtree, `--harness`), printing a per-project summary and confirming first; it needs login. Authed uploads are recorded on the manifest record (`SyncRecord.uploads`: server, repo, graph id, URL, source stamp); `--all` skips sessions recorded as uploaded to that destination, unchanged or changed (no update endpoint exists, so a changed session would only duplicate), and single `share` short-circuits on an unchanged one. `--force` ignores the record. `put_record` in `sync/engine.rs` carries `uploads` across sync/import rewrites. +- `path share` is the one-shot `p import | p export pathbase`: probes installed harnesses, aggregates sessions into one picker ranking current-directory sessions first; `--harness`/`--session`/`--project` skip the picker; pathbase flags match `p export pathbase`. When the sync manifest shows the picked session unchanged (`sync::fresh_cache_id`), it uploads the cached doc instead of re-deriving. Uploads carry the same full derivation as local projection — no egress stripping. `share --all` skips the picker and uploads every session in scope (`--project-under` subtree, `--harness`), printing a per-project summary and confirming first; it needs login. Authed uploads are recorded on the manifest record (`SyncRecord.uploads`: graph id, URL, source stamp; the URL `/u///graphs/` is what destination matching keys on); `--all` skips sessions recorded as uploaded to that destination, unchanged or changed (no update endpoint exists, so a changed session would only duplicate), and single `share` short-circuits on an unchanged one. `--force` ignores the record. `put_record` in `sync/engine.rs` carries `uploads` across sync/import rewrites. - Share also resolves a **configured share remote** from the session's own directory (the recorded cwd wherever the provider reports one — claude/pi carry it on `ConversationMetadata.cwd`/`SessionMeta.cwd` because their slug-decoded project paths are lossy; else `--project`; else the doc's `path.base`): `crates/path-cli/src/share_config.rs` checks `~/.toolpath/config.toml` `[[project]]` rules (`dir` subtree match, `~/`-expandable, most specific wins; `remote` = bare `owner/name` or a canonical Pathbase repo web URL `https:///u//`, which also carries the server — the URL scheme is the extension point for future backends, unknown schemes rejected). Precedence: `--repo` flag > config > `/pathstash`; `--url` beats a URL remote's embedded server. A resolved remote prints `Sharing to ()`; hitting one while logged out errors with a `path auth login` hint (no silent anon fall-through), and explicit `--anon` opts out of the mapping. Both sides of the subtree match are prefix-canonicalized (longest existing ancestor resolved, tail re-appended) so macOS `/var`→`/private/var` and deleted checkouts still match. A repo-tracked `.toolpath.toml` is deliberately **not** consulted — a committed file redirecting other users' uploads needs a first-use consent flow (issue #179). - `path resume ` is the inverse: accepts a Pathbase URL, `owner/repo/slug` shorthand, local file, or cache id; validates a single agent-bearing `Path`; opens a harness picker (pre-selecting `path.meta.source` when installed; `--harness` skips); projects the session into the harness's on-disk layout under `-C/--cwd` (default: shell cwd) and `execvp`'s the harness's resume command (spawn-and-wait on Windows). - `path query` plans before it scans: `crates/path-cli/src/query/plan.rs` classifies the jaq filter into `PerFileStream` (element-wise, print as you go), `Decompose` (algebraic aggregation with a derived combine), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative so **the planner never changes an answer**; `query/filter.rs` tests assert streamed output equals slurp byte-for-byte. Execution is parallel (`mod.rs::execute_plan`/`for_each_file`): `PerFileStream`/`Decompose` run the whole per-file pipeline (parse → wrap → filter → render/pack) on rayon workers in chunks — partials cross threads as compact JSON bytes since jaq `Val`s are `Rc`-based — while `Slurp` parallelizes only parse/wrap; output stays byte-identical to a sequential scan (ordering, warnings, error precedence), and the emscripten build stays fully sequential. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan; there is no user-facing flag. Caveat: a streamed top-N matches slurp's ranking, but boundary ties may resolve to different rows. diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index ab40769e..7e439766 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -870,7 +870,8 @@ fn share_explicit( .as_ref() .map(|r| format!("{}/{}", r.owner, r.name)) .unwrap_or_else(|| format!("{username}/pathstash")); - match upload_class(harness, session, &dest.base_url, &repo, stamp) { + let repo_url = repo_url(&dest.base_url, &repo); + match upload_class(harness, session, &repo_url, stamp) { UploadClass::Uploaded(url) if !args.force => { eprintln!( "Already uploaded to {repo}, unchanged since; pass --force to upload again" @@ -905,8 +906,7 @@ fn share_explicit( harness, session, project.as_deref(), - &base_url, - &format!("{}/{}", done.owner, done.repo), + &repo_url(&base_url, &format!("{}/{}", done.owner, done.repo)), &done.created, stamp, ); @@ -924,6 +924,11 @@ fn row_project(row: &ArtifactRow) -> Option<&str> { } } +/// `/u//` — the key upload records match on. +fn repo_url(base_url: &str, repo: &str) -> String { + format!("{}/u/{repo}", base_url.trim_end_matches('/')) +} + /// The manifest's view of one session relative to one destination. #[derive(Debug, Clone, PartialEq, Eq)] enum UploadClass { @@ -950,25 +955,23 @@ fn source_stamp( fn upload_class( harness: ArtifactType, session: &str, - server: &str, - repo: &str, + repo_url: &str, stamp: crate::sync::sources::Stamp, ) -> UploadClass { let manifest = crate::config::config_dir() .and_then(|dir| crate::sync::load_manifest(&dir)) .unwrap_or_default(); - classify(&manifest, harness, session, server, repo, stamp) + classify(&manifest, harness, session, repo_url, stamp) } fn classify( manifest: &crate::sync::Manifest, harness: ArtifactType, session: &str, - server: &str, - repo: &str, + repo_url: &str, stamp: crate::sync::sources::Stamp, ) -> UploadClass { - match crate::sync::upload_state(manifest, harness, session, server, repo, stamp) { + match crate::sync::upload_state(manifest, harness, session, repo_url, stamp) { crate::sync::UploadState::New => UploadClass::New, crate::sync::UploadState::Uploaded(u) => UploadClass::Uploaded(u.url.clone()), crate::sync::UploadState::Changed(u) => UploadClass::Changed(u.url.clone()), @@ -981,23 +984,20 @@ fn record_upload( harness: ArtifactType, session: &str, project: Option<&str>, - server: &str, - repo: &str, + repo_url: &str, created: &crate::cmd_pathbase::CreatedGraph, stamp: crate::sync::sources::Stamp, ) { let record = crate::sync::UploadRecord { - server: server.trim_end_matches('/').to_string(), - repo: repo.to_string(), graph_id: created.id.clone(), url: created.url.clone(), modified: stamp.0, size: stamp.1, uploaded_at: Utc::now(), }; - if let Err(e) = crate::config::config_dir() - .and_then(|dir| crate::sync::record_upload(&dir, harness, session, project, record)) - { + if let Err(e) = crate::config::config_dir().and_then(|dir| { + crate::sync::record_upload(&dir, harness, session, project, repo_url, record) + }) { eprintln!("warning: upload not recorded in sync manifest: {e}"); } } @@ -1326,8 +1326,7 @@ fn share_all( &manifest, row.artifact_type, &row.session_id, - &target.base_url, - &target.display(), + &target.url(), stamp, ) }, @@ -1411,8 +1410,7 @@ fn share_all( row.artifact_type, &row.session_id, project, - &target.base_url, - &target.display(), + &target.url(), &created, stamp, ); @@ -2076,8 +2074,6 @@ mod tests { use crate::sync::{Manifest, SyncRecord, UploadRecord}; let stamp = (Some("2026-01-01T00:00:00Z".parse().unwrap()), Some(10u64)); let upload = UploadRecord { - server: "https://pb.test".into(), - repo: "me/foo".into(), graph_id: "g1".into(), url: "https://pb.test/u/me/foo/graphs/g1".into(), modified: stamp.0, @@ -2096,38 +2092,21 @@ mod tests { uploads: vec![upload.clone()], }, ); - let c = |repo: &str, st| { - classify( - &manifest, - ArtifactType::Claude, - "s1", - "https://pb.test/", - repo, - st, - ) - }; - assert_eq!( - c("me/foo", stamp), - UploadClass::Uploaded(upload.url.clone()) - ); + let c = |repo_url: &str, st| classify(&manifest, ArtifactType::Claude, "s1", repo_url, st); + let foo = repo_url("https://pb.test/", "me/foo"); + assert_eq!(foo, "https://pb.test/u/me/foo"); + assert_eq!(c(&foo, stamp), UploadClass::Uploaded(upload.url.clone())); assert_eq!( - c("me/foo", (stamp.0, Some(11))), + c(&foo, (stamp.0, Some(11))), UploadClass::Changed(upload.url.clone()) ); assert_eq!( - c("me/foo", (None, None)), + c(&foo, (None, None)), UploadClass::Changed(upload.url.clone()) ); - assert_eq!(c("me/bar", stamp), UploadClass::New); + assert_eq!(c("https://pb.test/u/me/bar", stamp), UploadClass::New); assert_eq!( - classify( - &manifest, - ArtifactType::Codex, - "s1", - "https://pb.test", - "me/foo", - stamp - ), + classify(&manifest, ArtifactType::Codex, "s1", &foo, stamp), UploadClass::New ); } diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index fa2bf3d9..8ee45856 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -53,13 +53,11 @@ pub(crate) struct SyncRecord { /// One upload of an artifact to a Pathbase repo, with the source /// fingerprint at upload time so a later run can tell "unchanged -/// since" from "changed since". +/// since" from "changed since". The destination is not stored +/// separately: the graph URL the server returned is +/// `/u///graphs/`, so it names the repo. #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub(crate) struct UploadRecord { - /// Server base URL as used for the upload. - pub(crate) server: String, - /// `owner/name`. - pub(crate) repo: String, pub(crate) graph_id: String, pub(crate) url: String, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -70,12 +68,24 @@ pub(crate) struct UploadRecord { } impl UploadRecord { - fn same_destination(&self, server: &str, repo: &str) -> bool { - crate::cmd_pathbase::host_of(&self.server) == crate::cmd_pathbase::host_of(server) - && self.repo == repo + /// Whether this upload landed in the repo at `repo_url` + /// (`/u//`). Hosts are compared as written; + /// the graph URL must be `/graphs/…`, so a parent path + /// (`/u/`) never matches. + fn in_repo(&self, repo_url: &str) -> bool { + let repo_url = repo_url.trim_end_matches('/'); + let (host, path) = split_host(&self.url); + let (repo_host, repo_path) = split_host(repo_url); + host == repo_host && path.starts_with(&format!("{repo_path}/graphs/")) } } +/// `("https://host", "/rest")` — the authority and everything after it. +fn split_host(url: &str) -> (&str, &str) { + let host = crate::cmd_pathbase::host_of(url); + (host, &url[host.len()..]) +} + /// What the manifest knows about an artifact relative to one upload /// destination. #[derive(Debug, Clone, Copy, PartialEq)] @@ -89,25 +99,20 @@ pub(crate) enum UploadState<'a> { Changed(&'a UploadRecord), } -/// The artifact's upload state for `server`/`repo`, judged against the -/// current source stamp. Only a real, matching stamp can vouch for +/// The artifact's upload state for the repo at `repo_url` +/// (`/u//`), judged against the current source stamp. Only a real, matching stamp can vouch for /// "unchanged"; a `None` stamp on either side reads as changed. pub(crate) fn upload_state<'a>( manifest: &'a Manifest, artifact_type: ArtifactType, id: &str, - server: &str, - repo: &str, + repo_url: &str, current: sources::Stamp, ) -> UploadState<'a> { let Some(rec) = manifest .get(artifact_type.name()) .and_then(|records| records.get(id)) - .and_then(|rec| { - rec.uploads - .iter() - .find(|u| u.same_destination(server, repo)) - }) + .and_then(|rec| rec.uploads.iter().find(|u| u.in_repo(repo_url))) else { return UploadState::New; }; @@ -122,14 +127,15 @@ pub(crate) fn upload_state<'a>( } } -/// Record a successful upload. Replaces any prior record for the same -/// server+repo. Creates a known-but-uncached record when the artifact +/// Record a successful upload to the repo at `repo_url`. Replaces any +/// prior record in that repo. Creates a known-but-uncached record when the artifact /// has none yet (e.g. a `--no-cache` share). pub(crate) fn record_upload( config_dir: &Path, artifact_type: ArtifactType, id: &str, path: Option<&str>, + repo_url: &str, upload: UploadRecord, ) -> Result<()> { update_manifest(config_dir, |manifest| { @@ -145,8 +151,7 @@ pub(crate) fn record_upload( synced_at: Utc::now(), uploads: Vec::new(), }); - rec.uploads - .retain(|u| !u.same_destination(&upload.server, &upload.repo)); + rec.uploads.retain(|u| !u.in_repo(repo_url)); rec.uploads.push(upload); }) } @@ -618,8 +623,6 @@ mod tests { fn upload(server: &str, repo: &str, graph_id: &str) -> UploadRecord { UploadRecord { - server: server.to_string(), - repo: repo.to_string(), graph_id: graph_id.to_string(), url: format!("{server}/u/{repo}/graphs/{graph_id}"), modified: Some("2026-01-01T00:00:00Z".parse().unwrap()), @@ -631,31 +634,17 @@ mod tests { #[test] fn record_upload_creates_and_replaces_per_destination() { with_cfg(|_, cfg| { - record_upload( - cfg, - ArtifactType::Claude, - "s1", + let put = |path: Option<&str>, repo_url: &str, u: UploadRecord| { + record_upload(cfg, ArtifactType::Claude, "s1", path, repo_url, u).unwrap() + }; + put( Some("/p"), + "https://a/u/me/x", upload("https://a", "me/x", "g1"), - ) - .unwrap(); - record_upload( - cfg, - ArtifactType::Claude, - "s1", - None, - upload("https://a", "me/y", "g2"), - ) - .unwrap(); - // Same host, trailing slash: replaces the me/x record. - record_upload( - cfg, - ArtifactType::Claude, - "s1", - None, - upload("https://a/", "me/x", "g3"), - ) - .unwrap(); + ); + put(None, "https://a/u/me/y", upload("https://a", "me/y", "g2")); + // Trailing slash on the repo URL: still replaces the me/x record. + put(None, "https://a/u/me/x/", upload("https://a", "me/x", "g3")); let m = load_manifest(cfg).unwrap(); let rec = &m["claude"]["s1"]; assert_eq!(rec.path.as_deref(), Some("/p")); @@ -673,6 +662,7 @@ mod tests { ArtifactType::Claude, "s1", None, + "https://a/u/me/x", upload("https://a", "me/x", "g1"), ) .unwrap(); @@ -733,40 +723,25 @@ mod tests { }, ); let same = (Some("2026-01-01T00:00:00Z".parse().unwrap()), Some(10)); + let st = |id: &str, repo_url: &str, stamp| { + upload_state(&m, ArtifactType::Claude, id, repo_url, stamp) + }; assert!(matches!( - upload_state(&m, ArtifactType::Claude, "s1", "https://a", "me/x", same), + st("s1", "https://a/u/me/x", same), UploadState::Uploaded(u) if u.graph_id == "g1" )); assert!(matches!( - upload_state( - &m, - ArtifactType::Claude, - "s1", - "https://a", - "me/x", - (same.0, Some(11)) - ), + st("s1", "https://a/u/me/x", (same.0, Some(11))), UploadState::Changed(_) )); assert!(matches!( - upload_state( - &m, - ArtifactType::Claude, - "s1", - "https://a", - "me/x", - (None, None) - ), + st("s1", "https://a/u/me/x", (None, None)), UploadState::Changed(_) )); - assert_eq!( - upload_state(&m, ArtifactType::Claude, "s1", "https://b", "me/x", same), - UploadState::New - ); - assert_eq!( - upload_state(&m, ArtifactType::Claude, "s2", "https://a", "me/x", same), - UploadState::New - ); + assert_eq!(st("s1", "https://b/u/me/x", same), UploadState::New); + assert_eq!(st("s1", "https://a/u/me/xy", same), UploadState::New); + assert_eq!(st("s1", "https://a/u/me", same), UploadState::New); + assert_eq!(st("s2", "https://a/u/me/x", same), UploadState::New); } fn write_claude_session(home: &Path, project_slug: &str, session: &str, prompt: &str) { From 7b855b137db9fc6832d33f8d19759f1e0093487c Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Fri, 28 Aug 2026 10:33:07 -0400 Subject: [PATCH 4/7] match upload records by plain string prefix on the repo URL --- crates/path-cli/src/sync/engine.rs | 20 +++++++------------- 1 file changed, 7 insertions(+), 13 deletions(-) diff --git a/crates/path-cli/src/sync/engine.rs b/crates/path-cli/src/sync/engine.rs index 8ee45856..f7676d57 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -69,23 +69,17 @@ pub(crate) struct UploadRecord { impl UploadRecord { /// Whether this upload landed in the repo at `repo_url` - /// (`/u//`). Hosts are compared as written; - /// the graph URL must be `/graphs/…`, so a parent path - /// (`/u/`) never matches. + /// (`/u//`, built from the base URL and the + /// repo the upload targets): the graph URL must be + /// `/graphs/…`. A plain string match — a base URL that + /// differs from what the server echoes (scheme, host case) just + /// means one extra upload. fn in_repo(&self, repo_url: &str) -> bool { - let repo_url = repo_url.trim_end_matches('/'); - let (host, path) = split_host(&self.url); - let (repo_host, repo_path) = split_host(repo_url); - host == repo_host && path.starts_with(&format!("{repo_path}/graphs/")) + self.url + .starts_with(&format!("{}/graphs/", repo_url.trim_end_matches('/'))) } } -/// `("https://host", "/rest")` — the authority and everything after it. -fn split_host(url: &str) -> (&str, &str) { - let host = crate::cmd_pathbase::host_of(url); - (host, &url[host.len()..]) -} - /// What the manifest knows about an artifact relative to one upload /// destination. #[derive(Debug, Clone, Copy, PartialEq)] From b9f8133c38248035ccfec917ac6383a648a35d84 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Fri, 28 Aug 2026 10:43:19 -0400 Subject: [PATCH 5/7] share --all summary: count sessions to upload per row, note what is skipped Row counts and harness breakdowns now cover the sessions that will be uploaded; each row appends (N already uploaded, M changed since upload) so the skipped sessions can be located. The heading gains ", K to upload" when any are skipped. Rows with nothing to upload stay listed with 0. --- crates/path-cli/src/cmd_share.rs | 106 ++++++++++++++++--------------- 1 file changed, 56 insertions(+), 50 deletions(-) diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 7e439766..a604a26b 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -1103,7 +1103,7 @@ struct BulkGroup { rows: Vec, /// Manifest classification per row, parallel to `rows`. states: Vec, - /// Per-harness counts, most first. + /// Per-harness counts of the rows that will upload (`New`), most first. by_harness: Vec<(ArtifactType, usize)>, /// A remote configured for this directory, when one resolved and /// no `--repo` overrides it. @@ -1129,8 +1129,17 @@ fn plan_bulk( } let mut groups = Vec::with_capacity(by_dir.len()); for (dir, idxs) in by_dir { + let configured = match &dir { + Some(d) => remote(d)?, + None => None, + }; + let target = configured.as_ref().unwrap_or(default_target); + let states: Vec = idxs.iter().map(|&i| state(&rows[i], target)).collect(); let mut by_harness: Vec<(ArtifactType, usize)> = Vec::new(); - for &i in &idxs { + for (&i, st) in idxs.iter().zip(&states) { + if *st != UploadClass::New { + continue; + } let t = rows[i].artifact_type; match by_harness.iter_mut().find(|(h, _)| *h == t) { Some((_, n)) => *n += 1, @@ -1138,12 +1147,6 @@ fn plan_bulk( } } by_harness.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name()))); - let configured = match &dir { - Some(d) => remote(d)?, - None => None, - }; - let target = configured.as_ref().unwrap_or(default_target); - let states = idxs.iter().map(|&i| state(&rows[i], target)).collect(); groups.push(BulkGroup { dir, rows: idxs, @@ -1166,9 +1169,10 @@ fn plan_bulk( Ok(groups) } -/// The pre-upload summary: one line per project directory with its -/// session count, harness breakdown, and configured remote (if any), -/// followed by the fallback destination. +/// The pre-upload summary: one line per project directory with the +/// number of sessions that will upload, their harness breakdown, what +/// is being skipped (already uploaded / changed since), and the +/// configured remote (if any), followed by the fallback destination. fn render_bulk_summary( groups: &[BulkGroup], total: usize, @@ -1176,24 +1180,14 @@ fn render_bulk_summary( default_target: &BulkTarget, home: Option<&std::path::Path>, ) -> String { - let mut out = match project_under { - Some(p) => format!( - "Found {total} sessions under {}", - crate::config::home_relative(p, home) - ), - None => format!("Found {total} sessions"), - }; - let (mut uploaded, mut changed, mut new) = (0, 0, 0); - for g in groups { - for st in &g.states { - match st { - UploadClass::Uploaded(_) => uploaded += 1, - UploadClass::Changed(_) => changed += 1, - UploadClass::New => new += 1, - } - } + fn tally(states: &[UploadClass]) -> (usize, usize, usize) { + states.iter().fold((0, 0, 0), |(n, u, c), st| match st { + UploadClass::New => (n + 1, u, c), + UploadClass::Uploaded(_) => (n, u + 1, c), + UploadClass::Changed(_) => (n, u, c + 1), + }) } - if uploaded + changed > 0 { + fn skip_note(uploaded: usize, changed: usize) -> Option { let mut parts = Vec::new(); if uploaded > 0 { parts.push(format!("{uploaded} already uploaded")); @@ -1201,10 +1195,22 @@ fn render_bulk_summary( if changed > 0 { parts.push(format!("{changed} changed since upload")); } - parts.push(format!("{new} new")); - out.push_str(&format!(" ({})", parts.join(", "))); + (!parts.is_empty()).then(|| parts.join(", ")) + } + + let mut out = match project_under { + Some(p) => format!( + "Found {total} sessions under {}", + crate::config::home_relative(p, home) + ), + None => format!("Found {total} sessions"), + }; + let to_upload: usize = groups.iter().map(|g| tally(&g.states).0).sum(); + if to_upload != total { + out.push_str(&format!(", {to_upload} to upload")); } out.push('\n'); + let labels: Vec = groups .iter() .map(|g| match &g.dir { @@ -1213,9 +1219,10 @@ fn render_bulk_summary( }) .collect(); let label_width = labels.iter().map(|l| l.len()).max().unwrap_or(0); - let count_width = groups + let counts: Vec = groups.iter().map(|g| tally(&g.states).0).collect(); + let count_width = counts .iter() - .map(|g| g.rows.len().to_string().len()) + .map(|n| n.to_string().len()) .max() .unwrap_or(1); let breakdowns: Vec = groups @@ -1229,11 +1236,15 @@ fn render_bulk_summary( }) .collect(); let breakdown_width = breakdowns.iter().map(|b| b.len()).max().unwrap_or(0); - for ((g, label), breakdown) in groups.iter().zip(&labels).zip(&breakdowns) { + for (((g, label), breakdown), count) in groups.iter().zip(&labels).zip(&breakdowns).zip(&counts) + { let mut line = format!( - " {label:count_width$} {breakdown:count_width$} {breakdown: UploadClass::Uploaded("u".into()), + "a" | "b" | "e" => UploadClass::Uploaded("u".into()), "c" => UploadClass::Changed("u".into()), _ => UploadClass::New, }, ) .unwrap(); - let out = render_bulk_summary(&groups, 4, None, &target("me", "pathstash"), None); - assert!( - out.starts_with( - "Found 4 sessions (2 already uploaded, 1 changed since upload, 1 new)\n" - ), - "{out}" - ); + let out = render_bulk_summary(&groups, 5, None, &target("me", "pathstash"), None); assert_eq!( - groups[0] - .states - .iter() - .filter(|s| **s == UploadClass::New) - .count(), - 1 + out, + "Found 5 sessions, 1 to upload\n\ + \x20 /p 1 claude 1 (2 already uploaded, 1 changed since upload)\n\ + \x20 /q 0 (1 already uploaded)\n\ + Destination: me/pathstash\n" ); + assert_eq!(groups[0].by_harness, vec![(ArtifactType::Claude, 1)]); } #[test] From a7f16962ed848f30497b8aa62664bab5f5517b65 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Fri, 28 Aug 2026 10:49:24 -0400 Subject: [PATCH 6/7] share --all summary: compact layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harness totals move to one line under the heading; rows show the directory relative to --project-under ("." for the root, ~-relative otherwise), the count to upload, skip notes, and the configured remote, with no column padded to the widest breakdown. The destination folds into the prompt ("Upload N sessions to owner/repo? [y/N]"); --dry-run prints "Would upload …" instead. --- crates/path-cli/src/cmd_share.rs | 215 ++++++++++++++++--------------- 1 file changed, 113 insertions(+), 102 deletions(-) diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index a604a26b..43c72b05 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -1103,8 +1103,6 @@ struct BulkGroup { rows: Vec, /// Manifest classification per row, parallel to `rows`. states: Vec, - /// Per-harness counts of the rows that will upload (`New`), most first. - by_harness: Vec<(ArtifactType, usize)>, /// A remote configured for this directory, when one resolved and /// no `--repo` overrides it. configured: Option, @@ -1134,24 +1132,11 @@ fn plan_bulk( None => None, }; let target = configured.as_ref().unwrap_or(default_target); - let states: Vec = idxs.iter().map(|&i| state(&rows[i], target)).collect(); - let mut by_harness: Vec<(ArtifactType, usize)> = Vec::new(); - for (&i, st) in idxs.iter().zip(&states) { - if *st != UploadClass::New { - continue; - } - let t = rows[i].artifact_type; - match by_harness.iter_mut().find(|(h, _)| *h == t) { - Some((_, n)) => *n += 1, - None => by_harness.push((t, 1)), - } - } - by_harness.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name()))); + let states = idxs.iter().map(|&i| state(&rows[i], target)).collect(); groups.push(BulkGroup { dir, rows: idxs, states, - by_harness, configured, }); } @@ -1169,35 +1154,64 @@ fn plan_bulk( Ok(groups) } -/// The pre-upload summary: one line per project directory with the -/// number of sessions that will upload, their harness breakdown, what -/// is being skipped (already uploaded / changed since), and the -/// configured remote (if any), followed by the fallback destination. -fn render_bulk_summary( - groups: &[BulkGroup], - total: usize, +/// Per-harness session counts, most first, then by name. +fn harness_totals(rows: &[ArtifactRow]) -> Vec<(ArtifactType, usize)> { + let mut totals: Vec<(ArtifactType, usize)> = Vec::new(); + for row in rows { + match totals.iter_mut().find(|(h, _)| *h == row.artifact_type) { + Some((_, n)) => *n += 1, + None => totals.push((row.artifact_type, 1)), + } + } + totals.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.name().cmp(b.0.name()))); + totals +} + +/// How a group's directory is shown: relative to `--project-under` +/// when given (`.` for the root itself), else `~`-relative. +fn group_label( + dir: Option<&str>, project_under: Option<&std::path::Path>, - default_target: &BulkTarget, home: Option<&std::path::Path>, ) -> String { - fn tally(states: &[UploadClass]) -> (usize, usize, usize) { - states.iter().fold((0, 0, 0), |(n, u, c), st| match st { - UploadClass::New => (n + 1, u, c), - UploadClass::Uploaded(_) => (n, u + 1, c), - UploadClass::Changed(_) => (n, u, c + 1), - }) - } - fn skip_note(uploaded: usize, changed: usize) -> Option { - let mut parts = Vec::new(); - if uploaded > 0 { - parts.push(format!("{uploaded} already uploaded")); - } - if changed > 0 { - parts.push(format!("{changed} changed since upload")); + let Some(dir) = dir else { + return "(no project)".to_string(); + }; + let path = std::path::Path::new(dir); + if let Some(under) = project_under { + let candidates = [under.to_path_buf(), canonicalize_or_self(under)]; + if let Some(rest) = candidates.iter().find_map(|u| path.strip_prefix(u).ok()) { + return if rest.as_os_str().is_empty() { + ".".to_string() + } else { + rest.display().to_string() + }; } - (!parts.is_empty()).then(|| parts.join(", ")) } + crate::config::home_relative(path, home) +} +/// (new, already uploaded, changed since upload) for one group. +fn tally(states: &[UploadClass]) -> (usize, usize, usize) { + states.iter().fold((0, 0, 0), |(n, u, c), st| match st { + UploadClass::New => (n + 1, u, c), + UploadClass::Uploaded(_) => (n, u + 1, c), + UploadClass::Changed(_) => (n, u, c + 1), + }) +} + +/// The pre-upload summary: a heading with what was found and what +/// will upload, harness totals, then one line per project directory +/// with the number of sessions that will upload, what is being skipped +/// (already uploaded / changed since), and the configured remote if +/// any. The prompt line is the caller's. +fn render_bulk_summary( + groups: &[BulkGroup], + rows: &[ArtifactRow], + project_under: Option<&std::path::Path>, + home: Option<&std::path::Path>, +) -> String { + let total = rows.len(); let mut out = match project_under { Some(p) => format!( "Found {total} sessions under {}", @@ -1210,13 +1224,15 @@ fn render_bulk_summary( out.push_str(&format!(", {to_upload} to upload")); } out.push('\n'); + let totals: Vec = harness_totals(rows) + .iter() + .map(|(t, n)| format!("{} {n}", t.name())) + .collect(); + out.push_str(&format!(" {}\n\n", totals.join(", "))); let labels: Vec = groups .iter() - .map(|g| match &g.dir { - Some(d) => crate::config::home_relative(std::path::Path::new(d), home), - None => "(no project)".to_string(), - }) + .map(|g| group_label(g.dir.as_deref(), project_under, home)) .collect(); let label_width = labels.iter().map(|l| l.len()).max().unwrap_or(0); let counts: Vec = groups.iter().map(|g| tally(&g.states).0).collect(); @@ -1225,44 +1241,38 @@ fn render_bulk_summary( .map(|n| n.to_string().len()) .max() .unwrap_or(1); - let breakdowns: Vec = groups - .iter() - .map(|g| { - g.by_harness - .iter() - .map(|(t, n)| format!("{} {n}", t.name())) - .collect::>() - .join(", ") - }) - .collect(); - let breakdown_width = breakdowns.iter().map(|b| b.len()).max().unwrap_or(0); - for (((g, label), breakdown), count) in groups.iter().zip(&labels).zip(&breakdowns).zip(&counts) - { - let mut line = format!( - " {label:count_width$} {breakdown:count_width$}"); let (_, uploaded, changed) = tally(&g.states); - if let Some(note) = skip_note(uploaded, changed) { - line.push_str(&format!(" ({note})")); + let mut notes = Vec::new(); + if uploaded > 0 { + notes.push(format!("{uploaded} already uploaded")); + } + if changed > 0 { + notes.push(format!("{changed} changed since upload")); + } + if !notes.is_empty() { + line.push_str(&format!(" ({})", notes.join(", "))); } if let Some(t) = &g.configured { - line.push_str(&format!(" (→ {})", t.display())); + line.push_str(&format!(" → {}", t.display())); } - out.push_str(line.trim_end()); + out.push_str(&line); out.push('\n'); } - let any_configured = groups.iter().any(|g| g.configured.is_some()); - if any_configured { - out.push_str(&format!( - "Destination: {} unless noted\n", - default_target.display() - )); - } else { - out.push_str(&format!("Destination: {}\n", default_target.display())); - } + out.push('\n'); out } +/// "N sessions to " for the prompt and dry-run lines. +fn upload_phrase(to_upload: usize, groups: &[BulkGroup], default_target: &BulkTarget) -> String { + let mut phrase = format!("{to_upload} sessions to {}", default_target.display()); + if groups.iter().any(|g| g.configured.is_some()) { + phrase.push_str(" (or the noted remote)"); + } + phrase +} + fn share_all( args: &ShareArgs, harness: Option, @@ -1352,9 +1362,8 @@ fn share_all( "{}", render_bulk_summary( &groups, - rows.len(), + &rows, args.project_under.as_deref(), - &default_target, home.as_deref(), ) ); @@ -1362,12 +1371,13 @@ fn share_all( eprintln!("Nothing to upload; pass --force to upload everything again."); return Ok(()); } + let phrase = upload_phrase(to_upload, &groups, &default_target); if args.dry_run { + eprintln!("Would upload {phrase}"); return Ok(()); } if !args.yes { - let answer = - crate::cmd_pathbase::prompt_line(&format!("Upload {to_upload} sessions? [y/N] "))?; + let answer = crate::cmd_pathbase::prompt_line(&format!("Upload {phrase}? [y/N] "))?; if !matches!(answer.to_ascii_lowercase().as_str(), "y" | "yes") { eprintln!("Aborted."); std::process::exit(130); @@ -1967,14 +1977,6 @@ mod tests { let dirs: Vec> = groups.iter().map(|g| g.dir.as_deref()).collect(); assert_eq!(dirs, [Some("/w/bar"), Some("/w/foo"), None]); assert_eq!(groups[0].rows.len(), 3); - assert_eq!( - groups[0].by_harness, - vec![(ArtifactType::Cursor, 2), (ArtifactType::Claude, 1)] - ); - assert_eq!( - groups[1].by_harness, - vec![(ArtifactType::Claude, 2), (ArtifactType::Codex, 1)] - ); assert_eq!(groups[1].configured, Some(target("me", "foo"))); assert_eq!(groups[0].configured, None); assert_eq!(groups[2].configured, None); @@ -2001,6 +2003,7 @@ mod tests { bulk_row(ArtifactType::Claude, Some("/home/me/work/foo"), "f1"), bulk_row(ArtifactType::Codex, Some("/home/me/work/foo"), "f2"), bulk_row(ArtifactType::Claude, Some("/home/me/work/bar"), "b1"), + bulk_row(ArtifactType::Claude, Some("/home/me/work"), "r1"), bulk_row(ArtifactType::Cursor, None, "n1"), ]; let groups = plan_bulk( @@ -2012,24 +2015,30 @@ mod tests { .unwrap(); let out = render_bulk_summary( &groups, - rows.len(), + &rows, Some(Path::new("/home/me/work")), - &target("me", "pathstash"), Some(Path::new("/home/me")), ); assert_eq!( out, - "Found 4 sessions under ~/work\n\ - \x20 ~/work/foo 2 claude 1, codex 1 (→ me/foo)\n\ - \x20 ~/work/bar 1 claude 1\n\ - \x20 (no project) 1 cursor 1\n\ - Destination: me/pathstash unless noted\n" + "Found 5 sessions under ~/work\n\ + \x20 claude 3, codex 1, cursor 1\n\ + \n\ + \x20 foo 2 → me/foo\n\ + \x20 . 1\n\ + \x20 bar 1\n\ + \x20 (no project) 1\n\ + \n" + ); + assert_eq!( + upload_phrase(5, &groups, &target("me", "pathstash")), + "5 sessions to me/pathstash (or the noted remote)" ); } #[test] - fn render_bulk_summary_without_configured_remotes() { - let rows = vec![bulk_row(ArtifactType::Claude, Some("/p"), "s")]; + fn render_bulk_summary_without_project_under_uses_home_relative() { + let rows = vec![bulk_row(ArtifactType::Claude, Some("/home/me/p"), "s")]; let groups = plan_bulk( &rows, &target("me", "pathstash"), @@ -2037,10 +2046,11 @@ mod tests { |_, _| UploadClass::New, ) .unwrap(); - let out = render_bulk_summary(&groups, 1, None, &target("me", "pathstash"), None); + let out = render_bulk_summary(&groups, &rows, None, Some(Path::new("/home/me"))); + assert_eq!(out, "Found 1 sessions\n claude 1\n\n ~/p 1\n\n"); assert_eq!( - out, - "Found 1 sessions\n /p 1 claude 1\nDestination: me/pathstash\n" + upload_phrase(1, &groups, &target("me", "pathstash")), + "1 sessions to me/pathstash" ); } @@ -2056,7 +2066,7 @@ mod tests { let groups = plan_bulk( &rows, &target("me", "pathstash"), - |_| Ok(None), + |dir| Ok((dir == "/q").then(|| target("me", "q"))), |row, _| match row.session_id.as_str() { "a" | "b" | "e" => UploadClass::Uploaded("u".into()), "c" => UploadClass::Changed("u".into()), @@ -2064,15 +2074,16 @@ mod tests { }, ) .unwrap(); - let out = render_bulk_summary(&groups, 5, None, &target("me", "pathstash"), None); + let out = render_bulk_summary(&groups, &rows, None, None); assert_eq!( out, "Found 5 sessions, 1 to upload\n\ - \x20 /p 1 claude 1 (2 already uploaded, 1 changed since upload)\n\ - \x20 /q 0 (1 already uploaded)\n\ - Destination: me/pathstash\n" + \x20 claude 4, codex 1\n\ + \n\ + \x20 /p 1 (2 already uploaded, 1 changed since upload)\n\ + \x20 /q 0 (1 already uploaded) → me/q\n\ + \n" ); - assert_eq!(groups[0].by_harness, vec![(ArtifactType::Claude, 1)]); } #[test] From aa65192e60c9ff834172ffb87fa455cfa80d3672 Mon Sep 17 00:00:00 2001 From: Ben Barber Date: Fri, 28 Aug 2026 11:12:34 -0400 Subject: [PATCH 7/7] share: one status line per session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit load_session reports where the document came from instead of printing; single share keeps its one-line note, and --all completes its progress line in place: "[3/101] claude (cached) → ". --- crates/path-cli/src/cmd_share.rs | 62 +++++++++++++++++++++++--------- 1 file changed, 45 insertions(+), 17 deletions(-) diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index 43c72b05..1687d6d3 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -842,6 +842,13 @@ fn share_explicit( let loaded = load_session(harness, project.as_deref(), session, args.no_cache)?; let summary = format!("{} session {}", harness.name(), loaded.cache_id); + match &loaded.origin { + LoadOrigin::Cache => { + eprintln!("Cache is current for {summary}; uploading without re-deriving") + } + LoadOrigin::Derived(path) => eprintln!("Cached {summary} ({})", path.display()), + LoadOrigin::DerivedUncached => {} + } let session_dir = session_dir .or_else(|| project.as_deref().map(PathBuf::from)) .or_else(|| { @@ -1009,6 +1016,26 @@ struct LoadedSession { /// The parsed document when this load derived it; `None` when the /// body was read straight from the cache. doc: Option, + /// Where the load came from, for the caller's status line. + origin: LoadOrigin, +} + +enum LoadOrigin { + /// Read from the cache: the manifest showed the source unchanged. + Cache, + /// Derived from the source and written to the cache at this path. + Derived(PathBuf), + /// Derived in memory only (`--no-cache`). + DerivedUncached, +} + +impl LoadOrigin { + fn short(&self) -> &'static str { + match self { + LoadOrigin::Cache => "cached", + LoadOrigin::Derived(_) | LoadOrigin::DerivedUncached => "derived", + } + } } /// The current document for one session: read from the cache when the @@ -1032,18 +1059,16 @@ fn load_session( let doc_path = crate::cache::cache_path(&cache_id)?; let body = std::fs::read_to_string(&doc_path) .with_context(|| format!("Failed to read {}", doc_path.display()))?; - eprintln!( - "Cache is current for {} session {cache_id}; uploading without re-deriving", - harness.name() - ); return Ok(LoadedSession { cache_id, body, doc: None, + origin: LoadOrigin::Cache, }); } let derived = derive_session(harness, project, session)?; + let mut origin = LoadOrigin::DerivedUncached; if !no_cache { // Always overwrite: `share` ships the current state of the // session, and a stale cache file from a prior share would @@ -1058,18 +1083,14 @@ fn load_session( { eprintln!("warning: sync manifest not updated: {e}"); } - eprintln!( - "Cached {} session → {} ({})", - harness.name(), - derived.cache_id, - path.display() - ); + origin = LoadOrigin::Derived(path); } let body = derived.doc.to_json()?; Ok(LoadedSession { cache_id: derived.cache_id, body, doc: Some(derived.doc), + origin, }) } @@ -1403,8 +1424,14 @@ fn share_all( n += 1; let row = &rows[i]; let project = row_project(row); - let label = format!("{} {}", row.artifact_type.name(), row.session_id); - eprintln!("[{n}/{total}] {label}"); + // One line per session, completed in place: the prefix goes + // out before the (possibly slow) derive so progress is + // visible, the outcome finishes it. + eprint!( + "[{n}/{total}] {} {}", + row.artifact_type.name(), + row.session_id + ); let stamp = source_stamp(bundle, row.artifact_type, project, &row.session_id); let result = load_session(row.artifact_type, project, &row.session_id, args.no_cache) .and_then(|loaded| { @@ -1414,7 +1441,7 @@ fn share_all( .name .clone() .unwrap_or_else(|| crate::cmd_export::derive_name(&doc)); - crate::cmd_pathbase::graphs_post( + let created = crate::cmd_pathbase::graphs_post( &target.base_url, &token, &target.owner, @@ -1422,11 +1449,12 @@ fn share_all( Some(&name), &loaded.body, args.public, - ) + )?; + Ok((loaded.origin, created)) }); match result { - Ok(created) => { - eprintln!(" → {}", created.url); + Ok((origin, created)) => { + eprintln!(" ({}) → {}", origin.short(), created.url); record_upload( row.artifact_type, &row.session_id, @@ -1438,7 +1466,7 @@ fn share_all( *uploaded.entry(target.clone()).or_default() += 1; } Err(e) => { - eprintln!(" warning: {label} failed: {e:#}"); + eprintln!(" failed: {e:#}"); failed += 1; } }