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