feat: path share — one-shot interactive Pathbase upload#82
Merged
Conversation
Brainstormed design for a single-shot `path share` that aggregates sessions across installed agent harnesses, ranks current-project sessions first, and uploads the picked session to Pathbase. Captures the surface, aggregation/picker model, derive+upload pipeline, error handling, and test plan ahead of the implementation plan.
Nine tasks covering: refactors to lift derive helpers and split run_pathbase, scaffold for cmd_share, types, gather_sessions for all five harnesses, explicit-args path, picker + probe summary, and docs. Each task is TDD where reasonable (tests first when meaningful) with concrete code blocks per step.
Lifts DerivedDoc to pub(crate) and adds derive_{claude,gemini,pi}_pair
and derive_{codex,opencode}_one. These are the explicit-args paths
already exercised by the (Some(p), Some(s), _) arm of each existing
dispatch — extracted so cmd_share can reuse them without re-implementing
the per-harness wiring.
run_pathbase_inner takes a body string and a summary_source label, so callers with an in-memory toolpath document (cmd_share) can upload without round-tripping through the cache.
Code review flagged that the struct was defined unconditionally but only constructed from a #[cfg(not(target_os = "emscripten"))] block, producing a dead-code warning on wasm. Match the gate to its only caller.
Adds the cmd_share module with the full CLI surface (--url, --harness, --session, --project, --anon, --repo, --slug, --public, --force, --no-cache) and a stub run() that bails. Wires it into lib.rs as Commands::Share. Subsequent tasks fill in the body.
Pure data types plus from_arg/parse helpers and a project_keyed predicate. HarnessBundle::from_environment instantiates each provider unconditionally; gather_sessions (next task) skips providers whose listing returns empty or NotFound.
Aggregates SessionRow values from the three project-keyed providers, sorts cwd-matching rows first then by recency, and silently skips harnesses whose listing returns empty or NotFound. Codex and opencode land in the next commit.
Adds collect_codex/collect_opencode and the matching ranking/filter tests. Session-keyed providers compare canonical(stored_cwd) to canonical(cwd) for matches_cwd; project_filter applies to the same recorded cwd.
When --harness and --session are both set, share derives the session via cmd_import's pair helpers, optionally writes the cache, then uploads via cmd_export::run_pathbase_inner. Picker path follows.
Aggregates SessionRow values across installed harnesses, ranks cwd-matches first, and pipes them through fzf. Falls back to a manual-recipe message when fzf isn't available, and prints a probe summary when no harness has any sessions to share.
Without a fixture under $HOME, gather_sessions returns an empty Vec and share() bails through bail_no_sessions before the fzf-unavailable recipe path. Fixture builds a minimal claude session in a tempdir so the recipe path fires regardless of host environment.
…code`
The `path share` picker uses a unified preview template
`path show {harness} --project {key} --session {id}` for all five
supported harnesses. Codex and opencode previously rejected --project
with a clap error, breaking the preview pane for those rows. Accept
--project as a no-op (hidden from --help) so the unified template
renders correctly.
share_explicit takes session as a separate parameter, so the synthetic ShareArgs we built from the picker selection set the field then took it back out via .take().unwrap(). Just pass the destructured session String directly and leave the synthetic args's session field as None.
fzf::pick now returns PickResult { Selected, NoMatch, Cancelled } so
callers can distinguish a deliberate user cancel from no-match. cmd_share
exits 130 on Cancelled to match the spec; cmd_import preserves its
existing 'empty pairs → no documents produced' bail.
bail_no_sessions now reports each harness's base directory and whether it exists, instead of an unreachable 'not configured' branch that always read '0 sessions'. Helps users diagnose 'I have claude installed but path share says nothing'.
The 'Picked <harness> session <X>' line now prints the conversation title instead of the opaque session id, matching the spec. parse_picker_row returns the title alongside (harness, key, session_id).
Asserts the 'not logged in — uploading anonymously' stderr notice when share() falls through to anon without an explicit --anon flag. Closes a coverage gap from final review.
paths_match uses canonicalize_or_self for both arguments. A symlink pointing at a project directory and the directory itself should both canonicalize to the same path, so cwd-ranking works regardless of which form the user navigated through.
The default side-by-side preview pane gets cramped on narrow terminals. Add a preview_window field to fzf::PickOptions and switch the share picker to up:60%:wrap so the session preview gets the full terminal width. Existing import pickers keep right:60%:wrap (explicit at each call site).
Three small improvements after live-testing `path share --url <other-host>`: - api_me now reads the response body up-front, distinguishes 401/403 from generic non-success and from non-JSON bodies, and names the URL + re-auth command in every branch. Replaces the opaque "parsing /auth/me response: column 1" that hits when the URL points at something that isn't a Pathbase deployment. - 401s from paths_post and repos_post now carry the same shape: name the URL, point at `path auth login --url <url>`, and surface `--anon` as the no-auth fallback. - The host-mismatch warning in run_pathbase_inner is now actionable: it tells the user how to fix the situation instead of just predicting failure. Adds three short_body unit tests and updates the existing paths_post_401 assertion to the new contract (URL + re-auth hint + --anon hint).
…lure
Live testing turned up two related UX problems on `path share`:
1. The credentials check ran *after* the picker, so picking a session
against a `--url` whose credentials don't apply meant doing a bunch
of work just to fail at upload time.
2. A 401 was a hard error even when the user had no `--repo`/`--public`/
`--slug` flags forcing authed mode — anonymous would have worked just
fine.
Refactor:
- Add `cmd_pathbase::AuthMode { Anon, Authed }` and `preflight_auth` that
resolves credentials up front with a real `api_me` probe. On a probe
failure with no auth-requiring flags it falls back to `Anon` with a
stderr notice; with auth flags it propagates the error so the user
knows their explicit request can't be satisfied.
- `cmd_share::run` now runs preflight before the picker. On `--url
<other-host>` with mismatched creds, the failure now happens before
any session is picked, derived, or cached.
- `cmd_export::run_pathbase_inner` now takes a pre-resolved AuthMode +
base_url and stops doing its own credentials probing — so cmd_share
and cmd_export share the same authed-vs-anon decision logic.
- `host_of` and the supporting test moved to cmd_pathbase next to the
rest of the URL/auth helpers; cmd_share's `pathbase_host_for_picker`
collapses into the new `resolve_upload_base_url`.
Adds 5 preflight unit tests (+5, total 233 lib + 38 integration).
`path import` errors on cache-id collision because the user told it to import. `path share` is different — the cache write is incidental (upload uses the in-memory body), so colliding with a prior aborted share shouldn't block the upload that triggered the second run. When the cache entry for the derived id already exists and `--force` isn't set, share now logs "Reusing cached <h> session → <id>" and proceeds to upload from memory. `--force` still overwrites; explicit `--no-cache` still skips the cache entirely. Caught live: an earlier share against pathbase-dev had aborted mid-upload, leaving a cache entry that then blocked the retry with "cache entry … already exists; pass --force to overwrite" — even though the retry's whole purpose was to send the same data.
pathbase-dev returns `{"id", "share_url", "path"}` for successful
anon uploads while the OpenAPI spec the generated `pathbase-client`
was built against says `{"id", "url"}`. progenitor's strict response
decode rejects the deployment's response with
Error: anon upload failed: Invalid Response Payload: missing field `url`
— even though the upload itself succeeded.
Bypass the generated client for this endpoint (same approach as
`paths_download`) and accept any of `share_url` / `url` / `path` as
the location field. Adds three regression tests: share_url-only,
path-only, and a body-in-error check on 5xx.
The previous "reuse existing cache entry" behavior was wrong: the upload always uses the freshly-derived in-memory body, so when a conversation has grown since the prior share, the upload would contain the new turns while the cache file would still hold the older version. Cache and upload would silently disagree. Right semantics for `path share`: the cache reflects what was just uploaded. The user invoking share is asking to ship the current state of the session, and the cache is incidental disk persistence of that exact payload — so always overwrite. Drops the --force flag from share's CLI surface (it's a no-op now; overwriting is the default and only behavior). --no-cache still skips the cache write entirely. Adds an integration test that locks in the contract: derive a 2-turn session, share it, append 2 more turns to the JSONL, share again, assert the cache file now contains the new turn.
The "reason:" line that followed "falling back to anonymous" dumped
the full api_me error including HTML body snippets and serde
diagnostics — readable as a debugger trace, not as a CLI notice.
For a graceful fallthrough the user just needs to know what's
happening:
note: <one-line-cause>; falling back to anonymous upload.
Refactor:
- api_me errors are now terse one-liners ("rejected the stored
credentials", "isn't a Pathbase deployment", "returned <status>")
with no actionable hints baked in.
- preflight_auth's fallback path uses the terse error directly.
- preflight_auth's propagate path (--repo / --public / --slug set)
carries the "Run `path auth login --url <X>` …" hint, where it's
actually relevant.
Also fixes a parallelism flake: the unit-test EnvGuard was racing
on TOOLPATH_CONFIG_DIR across cargo test threads. Add a static
mutex held for the guard's lifetime so EnvGuard-using tests run
serially against each other.
Both the anon and authed branches of run_pathbase_inner used to print the URL on stdout *first* and the "Uploaded …" summary on stderr *second*. In a terminal that puts the summary below the URL, which buries the link the user actually wants to copy. Swap the order: summary on stderr first, URL on stdout last. The share URL is now the final line every time, regardless of whether both streams are merging in the terminal.
The original split between '_pair' (project-keyed providers — claude, gemini, pi take a project+session pair) and '_one' (session-keyed providers — codex, opencode take just a session id) leaned on internal jargon that no reader could intuit. The asymmetry was already expressed in the parameter list; the suffix was redundant. Rename all five helpers to derive_<harness>_session so they read uniformly: 'derive one session, given the args needed to identify it.' Also rename the dispatcher in cmd_share from derive_one to derive_session for symmetry.
Pathbase emits OpenAPI 3.1 ('type': ['string', 'null']), but our
generator stack (progenitor 0.14 / openapiv3 2.x) only understands 3.0
('type': 'string', 'nullable': true). Refreshing the spec now triggers
'not yet implemented: invalid type: null' at build time.
Add a jq down-converter to the refresh script that handles the two
3.1 idioms appearing in the live spec:
- Type-as-array nullable: 'type': ['X', 'null'] -> 'type': 'X' +
'nullable': true. Multi-type unions are rejected with a clear
error so we notice if the spec ever uses something more exotic.
- Nullable refs: 'oneOf': [{'type': 'null'}, {'\$ref': X}] ->
'allOf': [{'\$ref': X}] + 'nullable': true.
After this, 'PATHBASE_URL=https://pathbase-dev.fly.dev bash
scripts/refresh-pathbase-openapi.sh' produces a spec the build script
can consume without panicking.
The previous spec was stale enough that the actual server response
shapes had drifted, causing strict-deser failures on the typed client
(e.g. AnonUploadResponse: spec said {id, url}, server returns
{id, path, share_url}).
Refresh from https://pathbase-dev.fly.dev (the canonical live source)
through the down-converter in scripts/refresh-pathbase-openapi.sh.
Notable new shapes the typed client now agrees on:
- AnonUploadResponse: {id, path, share_url}
- User: required uuid + created_at/updated_at, plus optional
email/display_name/avatar_url/bio
- Several new endpoints (graphs, profile, health, signups) that the
CLI doesn't use yet but now compile-time-typed if it ever does
…base-client
api_me, api_logout, anon_paths_post, and paths_download were each
hand-rolled via blocking reqwest with assorted reasons (endpoint not
in spec, response-shape drift, byte-fidelity). With the spec
refreshed from pathbase-dev, those reasons no longer apply for these
four — convert them to the generated typed client so the only
remaining hand-rolled HTTP is api_redeem (which still isn't in the
spec, see the comment in cmd_pathbase.rs).
Behavioral changes:
- api_me now hits /api/v1/users/me (renamed from /api/v1/auth/me in
the spec). Maps the wire User into the lean local User used for
credentials persistence; no on-disk format change.
- api_logout still posts to /api/v1/auth/logout, just typed now.
- anon_paths_post returns AnonUploadResponse {id, share_url} from the
server's {id, path, share_url}. The previous "accept any of url /
share_url / path" fallback is gone — the typed client enforces the
spec. Still maps 413 to the 'log in for a listable upload' notice
and 429 to rate-limit advice.
- paths_download decodes through serde_json::Map and re-serializes;
byte-fidelity isn't a real requirement because the consumer parses
to Graph and re-serializes regardless. The downstream cache writer
writes pretty-printed JSON anyway.
host_of moves to cmd_pathbase, where it's already used by
preflight_auth. cmd_export imports it from there. Three test mocks
(two in integration.rs, one in cmd_pathbase.rs) update from {id, url}
to the new {id, path, share_url} shape so the typed client can decode
them. The fragile 'accepts share_url / path / url' tolerance tests
are gone — the spec is now the single source of truth.
A real upload to pathbase-dev returned
Error: upload to alex/pathstash failed: Communication Error: error sending request for url (...)
— useless: the user can't tell whether it's a timeout, a TLS issue, a
connect refusal, or a body error. progenitor's CommunicationError
wraps a reqwest::Error, but the default Display only shows the top
level. The actually-useful detail (Connection refused / handshake
failed / Timeout etc.) sits two or three levels down in source().
Add two helpers:
- full_chain(err): walks std::error::Error::source() and joins each
link's Display with ": ".
- reqwest_hint(err): classifies the reqwest error via is_timeout /
is_connect / is_body / is_decode and returns a short hint, falling
back to full_chain when no specific bucket fits.
Wire both into the catch-all arms of api_me, api_logout,
anon_paths_post, paths_post, repos_post, and paths_download. The
fallback `Err(e)` arms now use full_chain instead of plain `{e}`.
After this:
Error: upload to alex/pathstash failed: request timed out after 30s — try again, or shrink the upload
Error: upload to alex/pathstash failed: couldn't connect to server: error sending request: dns error: failed to lookup address: nodename nor servname provided
The pathbase-dev spec gained a few things since the last refresh,
and one of them was previously the only reason for hand-rolled HTTP
in this module: /api/v1/auth/cli/redeem now appears in the OpenAPI
contract with a documented RedeemBody / RedeemResponse pair.
Changes:
* scripts/refresh-pathbase-openapi.sh: drop operations whose
request/response bodies use content types other than
application/json. The new spec includes a NDJSON-streamed
/paths/.../steps endpoint; progenitor 0.14 panics on it
('UnexpectedFormat: unexpected content type: application/x-ndjson').
The CLI doesn't use that surface, so strip it during refresh.
* crates/pathbase-client/openapi.json: refresh from
https://pathbase-dev.fly.dev. New endpoints (sessions, dev auth,
github callback, signups) come along for the ride; the only ones
the CLI cares about kept their shapes.
* api_redeem now goes through client.cli_redeem; http_client and
the local RedeemResponse struct are gone. With this change every
Pathbase HTTP call goes through the typed client.
* Doc-tighten: paths_post mentions that is_public defaults to true
on the server, so we always pass Some(false) explicitly to keep
share's default behavior secret. paths_download notes that private
paths return 404 (not 401) per the new spec, with the error
pointing at "try the UUID, or path auth login". cmd_pathbase's
module-level doc no longer claims to host an HTTP client.
cargo test -p path-cli: 233 lib + 39 integration green.
cargo clippy --workspace -- -D warnings: clean.
pnpm 11.0.8 (latest) requires Node.js >= 22.13. The deploy-site workflow was pinned to Node 20, so `pnpm install` aborted with ERR_UNKNOWN_BUILTIN_MODULE on `node:sqlite`.
|
🔍 Preview deployed: https://62d6a2b8.toolpath.pages.dev |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds a new
path shareCLI command that aggregates agent sessions across installed harnesses (claude/gemini/codex/opencode/pi), ranks current-project sessions first in a single fzf picker, and uploads the picked session to Pathbase in one shot. Replaces the two-steppath import <h>+path export pathbase --input <id>workflow.path share --harness X --session Y [--project P]skips the picker entirely.--url,--anon,--repo,--slug,--public) matchpath export pathbase. Default cache write mirrorspath import;--no-cacheskips.Spec:
docs/superpowers/specs/2026-05-07-path-share-command-design.md. Plan:docs/superpowers/plans/2026-05-07-path-share-command.md.Test plan
cargo test --workspace— 1466 passing, 0 failingcargo clippy --workspace -- -D warnings— cleancargo clippy -p path-cli --tests -- -D warnings— cleanshare_help_lists_unified_picker_flags,share_explicit_args_uploads_via_anon,share_writes_cache_by_default,share_no_cache_skips_write,share_logged_out_anon_default,share_filters_by_project_with_no_matches_errors,share_no_harness_non_tty_prints_recipe)path share)path share --harness codex --session <id>round-trip against staging PathbaseNeed help on this PR? Tag
@codesmithwith what you need.