Skip to content

feat: path share — one-shot interactive Pathbase upload#82

Merged
akesling merged 36 commits into
mainfrom
feat/path-share
May 8, 2026
Merged

feat: path share — one-shot interactive Pathbase upload#82
akesling merged 36 commits into
mainfrom
feat/path-share

Conversation

@akesling
Copy link
Copy Markdown
Contributor

@akesling akesling commented May 8, 2026

Summary

Adds a new path share CLI 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-step path import <h> + path export pathbase --input <id> workflow.

  • Unified picker: cwd-matching sessions ranked first, then by recency. Stacked preview pane for narrow terminals.
  • Explicit-args path: path share --harness X --session Y [--project P] skips the picker entirely.
  • Detection-by-probing: silently skips harnesses whose home dir is missing or whose listing is empty; surfaces a probe summary (with paths) when nothing is shareable.
  • Pathbase flags (--url, --anon, --repo, --slug, --public) match path export pathbase. Default cache write mirrors path import; --no-cache skips.
  • Cancel-aware: Esc/Ctrl-C in the picker exits 130 so scripts can distinguish cancel from success.

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 failing
  • cargo clippy --workspace -- -D warnings — clean
  • cargo clippy -p path-cli --tests -- -D warnings — clean
  • Unit: 11 cmd_share tests cover Harness helpers, gather_sessions per harness, ranking, filters, picker TSV roundtrip, symlink canonicalization
  • Integration: 6 share tests (share_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)
  • Manual smoke: interactive picker on a machine with installed harnesses (path share)
  • Manual smoke: path share --harness codex --session <id> round-trip against staging Pathbase

View in Codesmith
Need help on this PR? Tag @codesmith with what you need.

  • Let Codesmith autofix CI failures and bot reviews

akesling added 22 commits May 7, 2026 15:37
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).
@akesling akesling force-pushed the feat/path-share branch from c5f0b7d to 052b174 Compare May 8, 2026 04:43
akesling added 7 commits May 8, 2026 09:31
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.
@akesling akesling force-pushed the feat/path-share branch from 9ea396f to 19260b2 Compare May 8, 2026 19:15
akesling added 6 commits May 8, 2026 16:32
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`.
@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 8, 2026

🔍 Preview deployed: https://62d6a2b8.toolpath.pages.dev

@akesling akesling merged commit 16275cf into main May 8, 2026
2 checks passed
@akesling akesling deleted the feat/path-share branch May 8, 2026 21:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant