diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eecd9e2..58a29f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,43 @@ 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. +- **`path-cli`**: authed uploads from `share` are recorded in the sync + 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 + 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 + 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..c43430a7 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. 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. @@ -225,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/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..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`. @@ -2033,7 +2047,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..1687d6d3 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -57,6 +57,56 @@ 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, + + /// 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. +#[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 +114,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 +134,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 +203,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 +217,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 +229,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 +251,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 +265,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 +296,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 +310,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 +338,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 +354,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 +368,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 +394,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 +409,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 +434,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 +447,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 +473,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 +494,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 +541,21 @@ 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) + .context("`share --all` requires an authenticated upload")?; + 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 +623,26 @@ pub fn run(args: ShareArgs) -> Result<()> { None }, no_cache: args.no_cache, + all: false, + 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 // 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 +819,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,52 +840,239 @@ 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); + 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(|| { + 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 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")); + 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" + ); + 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, + repo: dest.repo, + name: args.name.clone(), + public: args.public, + }; + 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(), + &repo_url(&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 + } +} + +/// `/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 { + 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, + 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, repo_url, stamp) +} + +fn classify( + manifest: &crate::sync::Manifest, + harness: ArtifactType, + session: &str, + repo_url: &str, + stamp: crate::sync::sources::Stamp, +) -> UploadClass { + 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()), + } +} + +/// 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>, + repo_url: &str, + created: &crate::cmd_pathbase::CreatedGraph, + stamp: crate::sync::sources::Stamp, +) { + let record = crate::sync::UploadRecord { + 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, repo_url, record) + }) { + eprintln!("warning: upload not recorded in sync manifest: {e}"); + } +} + +/// 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, + /// 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 +/// 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, ) { 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() - ); - 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, + origin: LoadOrigin::Cache, }); - 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)?; + 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 + // 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 @@ -833,28 +1083,418 @@ fn share_explicit( { eprintln!("warning: sync manifest not updated: {e}"); } - eprintln!( - "Cached {} session → {} ({})", - harness.name(), - derived.cache_id, - path.display() - ); + origin = LoadOrigin::Derived(path); } - - 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), + origin, + }) +} + +// ── `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, + /// Manifest classification per row, parallel to `rows`. + states: Vec, + /// 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` 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(); + 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 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, + states, + 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) +} + +/// 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>, + home: Option<&std::path::Path>, +) -> String { + let Some(dir) = dir else { + return "(no project)".to_string(); }; - crate::cmd_export::run_pathbase_inner(auth, dest.base_url, upload, &body, &summary) + 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() + }; + } + } + 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 {}", + 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 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| 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(); + let count_width = counts + .iter() + .map(|n| n.to_string().len()) + .max() + .unwrap_or(1); + for ((g, label), count) in groups.iter().zip(&labels).zip(&counts) { + let mut line = format!(" {label:count_width$}"); + let (_, uploaded, changed) = tally(&g.states); + 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())); + } + out.push_str(&line); + out.push('\n'); + } + 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, + 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 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, + &default_target, + |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) + }, + |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.url(), + 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!( + "{}", + render_bulk_summary( + &groups, + &rows, + args.project_under.as_deref(), + home.as_deref(), + ) + ); + if to_upload == 0 { + 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 {phrase}? [y/N] "))?; + 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 = to_upload; + let mut n = 0usize; + for group in &groups { + let target = group.configured.as_ref().unwrap_or(&default_target); + for (&i, state) in group.rows.iter().zip(&group.states) { + if *state != UploadClass::New { + continue; + } + n += 1; + let row = &rows[i]; + let project = row_project(row); + // 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| { + 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)); + let created = crate::cmd_pathbase::graphs_post( + &target.base_url, + &token, + &target.owner, + &target.repo, + Some(&name), + &loaded.body, + args.public, + )?; + Ok((loaded.origin, created)) + }); + match result { + Ok((origin, created)) => { + eprintln!(" ({}) → {}", origin.short(), created.url); + record_upload( + row.artifact_type, + &row.session_id, + project, + &target.url(), + &created, + stamp, + ); + *uploaded.entry(target.clone()).or_default() += 1; + } + Err(e) => { + eprintln!(" 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 +1908,254 @@ 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], + &target("me", "pathstash"), + |_| Ok(None), + |_, _| UploadClass::New, + ) + .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 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(); + assert_eq!(dirs, [Some("/w/bar"), Some("/w/foo"), None]); + assert_eq!(groups[0].rows.len(), 3); + 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"); + 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] + 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::Claude, Some("/home/me/work"), "r1"), + bulk_row(ArtifactType::Cursor, None, "n1"), + ]; + 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, + &rows, + Some(Path::new("/home/me/work")), + Some(Path::new("/home/me")), + ); + assert_eq!( + out, + "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_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"), + |_| Ok(None), + |_, _| UploadClass::New, + ) + .unwrap(); + 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!( + upload_phrase(1, &groups, &target("me", "pathstash")), + "1 sessions to me/pathstash" + ); + } + + #[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::Codex, Some("/p"), "c"), + bulk_row(ArtifactType::Claude, Some("/p"), "d"), + bulk_row(ArtifactType::Claude, Some("/q"), "e"), + ]; + let groups = plan_bulk( + &rows, + &target("me", "pathstash"), + |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()), + _ => UploadClass::New, + }, + ) + .unwrap(); + let out = render_bulk_summary(&groups, &rows, None, None); + assert_eq!( + out, + "Found 5 sessions, 1 to upload\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" + ); + } + + #[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 { + 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_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(&foo, (stamp.0, Some(11))), + UploadClass::Changed(upload.url.clone()) + ); + assert_eq!( + c(&foo, (None, None)), + UploadClass::Changed(upload.url.clone()) + ); + assert_eq!(c("https://pb.test/u/me/bar", stamp), UploadClass::New); + assert_eq!( + classify(&manifest, ArtifactType::Codex, "s1", &foo, stamp), + UploadClass::New + ); + } + #[test] fn parse_picker_row_roundtrips_keyed() { let row = ArtifactRow { @@ -1408,6 +2296,11 @@ mod tests { session: None, project: None, no_cache: false, + all: false, + 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..f7676d57 100644 --- a/crates/path-cli/src/sync/engine.rs +++ b/crates/path-cli/src/sync/engine.rs @@ -44,6 +44,110 @@ 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". 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 { + 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 { + /// Whether this upload landed in the repo at `repo_url` + /// (`/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 { + self.url + .starts_with(&format!("{}/graphs/", repo_url.trim_end_matches('/'))) + } +} + +/// 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 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, + 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.in_repo(repo_url))) + 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 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| { + 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.in_repo(repo_url)); + rec.uploads.push(upload); + }) } /// The sync manifest: artifact type (`"claude"`, `"codex"`, …) → @@ -153,6 +257,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 +280,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 +356,7 @@ fn sync_artifacts( modified: artifact.modified, size: artifact.size, synced_at: Utc::now(), + uploads: Vec::new(), }, ); unflushed += 1; @@ -276,6 +392,7 @@ fn sync_artifacts( modified: artifact.modified, size: artifact.size, synced_at: Utc::now(), + uploads: Vec::new(), }, ); unflushed += 1; @@ -310,19 +427,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 +615,129 @@ mod tests { result } + fn upload(server: &str, repo: &str, graph_id: &str) -> UploadRecord { + UploadRecord { + 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| { + 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"), + ); + 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")); + 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, + "https://a/u/me/x", + 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)); + let st = |id: &str, repo_url: &str, stamp| { + upload_state(&m, ArtifactType::Claude, id, repo_url, stamp) + }; + assert!(matches!( + st("s1", "https://a/u/me/x", same), + UploadState::Uploaded(u) if u.graph_id == "g1" + )); + assert!(matches!( + st("s1", "https://a/u/me/x", (same.0, Some(11))), + UploadState::Changed(_) + )); + assert!(matches!( + st("s1", "https://a/u/me/x", (None, None)), + UploadState::Changed(_) + )); + 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) { let project_dir = home.join(".claude/projects").join(project_slug); std::fs::create_dir_all(&project_dir).unwrap(); @@ -552,6 +793,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(); 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",