Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
36 commits
Select commit Hold shift + click to select a range
b3ee214
docs(specs): add design for `path share` command
akesling May 7, 2026
f8bb2c4
docs(plans): add implementation plan for `path share`
akesling May 7, 2026
3959146
refactor(path-cli): extract single-pair derive helpers
akesling May 7, 2026
58609d0
refactor(path-cli): split run_pathbase into wrapper + inner
akesling May 7, 2026
de264c0
fix(path-cli): gate PathbaseUploadArgs to native target
akesling May 7, 2026
12d14c6
feat(path-cli): scaffold `path share` command
akesling May 7, 2026
09b1654
feat(path-cli): add Harness, SessionRow, HarnessBundle types
akesling May 7, 2026
f654f96
feat(path-cli): implement gather_sessions for claude/gemini/pi
akesling May 7, 2026
8f34768
feat(path-cli): cover codex+opencode in gather_sessions
akesling May 8, 2026
6bffafc
feat(path-cli): implement `path share` explicit-args path
akesling May 8, 2026
cc6941a
chore(path-cli): drop useless format! in codex test fixture
akesling May 8, 2026
5a727c0
feat(path-cli): wire the unified `path share` picker
akesling May 8, 2026
2d4b25a
test(path-cli): make share-recipe test independent of $HOME
akesling May 8, 2026
51c4038
fix(path-cli): accept hidden --project on `show codex` and `show open…
akesling May 8, 2026
67a67c7
refactor(path-cli): drop synthetic session field in picker dispatch
akesling May 8, 2026
26227cc
docs: document `path share` in CLAUDE.md
akesling May 8, 2026
464d3d0
feat(path-cli): exit 130 on `path share` fzf cancel
akesling May 8, 2026
8bda7d0
feat(path-cli): probe harness paths in no-sessions message
akesling May 8, 2026
1655b86
feat(path-cli): show session title in picker confirmation
akesling May 8, 2026
7f29f16
test(path-cli): cover logged-out anon-default share path
akesling May 8, 2026
a177fde
test(path-cli): verify matches_cwd through a symlink
akesling May 8, 2026
052b174
feat(path-cli): stack `path share` picker preview above the list
akesling May 8, 2026
3684b2c
feat(path-cli): clearer auth-failure errors on `path share`
akesling May 8, 2026
cafa1bb
feat(path-cli): pre-flight Pathbase auth and fall back to anon on fai…
akesling May 8, 2026
4004eea
fix(path-cli): reuse existing cache entry on `path share` re-run
akesling May 8, 2026
15c3e24
fix(path-cli): accept `share_url` / `path` from anon-upload response
akesling May 8, 2026
54d5d73
fix(path-cli): always rewrite share cache so it matches the upload
akesling May 8, 2026
94526cf
fix(path-cli): terse fallback notice on `path share` auth fallthrough
akesling May 8, 2026
ced5365
fix(path-cli): print share URL last on `path share` upload
akesling May 8, 2026
19260b2
rename(path-cli): unify derive helper suffix to '_session'
akesling May 8, 2026
a4b5e19
build: down-convert OpenAPI 3.1 to 3.0 in refresh script
akesling May 8, 2026
4c21e2e
chore(pathbase-client): refresh OpenAPI spec from pathbase-dev
akesling May 8, 2026
4324c8b
refactor(path-cli): route the four hand-rolled endpoints through path…
akesling May 8, 2026
4283a31
fix(path-cli): show full error chain on Pathbase upload failure
akesling May 8, 2026
b492274
refactor(path-cli): refresh spec; route api_redeem through typed client
akesling May 8, 2026
5936f74
ci: bump site build Node to 22
akesling May 8, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy-site.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ jobs:

- uses: actions/setup-node@v4
with:
node-version: 20
node-version: 22
cache: pnpm
cache-dependency-path: site/pnpm-lock.yaml

Expand Down
6 changes: 6 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ cargo run -p path-cli -- import pi --project /path/to/project
cargo run -p path-cli -- import pathbase <pathbase-url-or-owner/repo/slug>
cargo run -p path-cli -- import claude --project . --no-cache | path render md --input -

# 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 <session-id> --project /path/to/project
cargo run -p path-cli -- share --url https://my-pathbase.example

# Export toolpath documents into external formats. <ref> is a cache id or a file path.
cargo run -p path-cli -- export claude --input <ref> --project /tmp/sandbox
cargo run -p path-cli -- export claude --input <ref> --output conv.jsonl
Expand Down Expand Up @@ -218,3 +223,4 @@ Build the site after changes: `cd site && pnpm run build` (should produce 7 page
- Format references for the agent on-disk formats we derive from live at `docs/agents/formats/`. The Claude Code format (`~/.claude/projects/…` JSONL) gets the deepest treatment — twelve focused docs at `docs/agents/formats/claude-code/` covering envelope, entry types, tools, session chains, compaction, writing-compatible JSONL, a linear walkthrough, and a version-keyed changelog. Sibling single-file references: `codex.md`, `gemini.md`, `opencode.md`. Keep them in sync with their derive crates when fields or behaviors change.
- Interactive session selection: `path import <provider>` (claude / gemini / pi / codex / opencode) auto-launches `fzf` when stdin and stderr are TTYs, `fzf` is on `$PATH`, and no `--session` was given. Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show <provider> --…` as its `--preview` command. When fzf isn't available, it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path list <provider> --format tsv` is the documented machine-readable surface — column 1 is the project (for claude/gemini/pi) or session id (for codex/opencode), and the trailing column carries `first_user_message` so consumers can fuzzy-match by topic.
- Conversation metadata title field: `toolpath-claude::ConversationMetadata`, `toolpath-gemini::ConversationMetadata`, and `toolpath-pi::SessionMeta` all expose `first_user_message: Option<String>` — the first non-empty user-prompt text. Populated cheaply during the metadata pass (single-pass for Claude/Gemini; one extra short read for Pi). Used by the picker UI but useful for any "list sessions by topic" surface.
- `path share` is the one-shot equivalent of `path import <harness> | path export pathbase`. It probes installed agent harnesses (claude/gemini/codex/opencode/pi), aggregates their sessions into a single fzf picker, and ranks rows whose project (claude/gemini/pi) or recorded cwd (codex/opencode) canonicalizes to the current directory at the top. `--harness` narrows the picker to one provider; `--harness X --session Y` (and `--project P` for keyed providers) skips the picker entirely. Pathbase flags (`--url`, `--anon`, `--repo`, `--slug`, `--public`) match `path export pathbase`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip.
240 changes: 116 additions & 124 deletions crates/path-cli/src/cmd_export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -153,12 +153,12 @@ pub enum ExportTarget {

/// `owner/name` pair for `--repo`.
#[derive(Debug, Clone)]
pub struct RepoSpec {
pub owner: String,
pub name: String,
pub(crate) struct RepoSpec {
pub(crate) owner: String,
pub(crate) name: String,
}

fn parse_repo_spec(s: &str) -> std::result::Result<RepoSpec, String> {
pub(crate) fn parse_repo_spec(s: &str) -> std::result::Result<RepoSpec, String> {
let (owner, name) = s
.split_once('/')
.ok_or_else(|| format!("expected owner/name, got `{s}`"))?;
Expand Down Expand Up @@ -226,6 +226,19 @@ struct PathbaseExportArgs {
public: bool,
}

/// Pathbase upload knobs that don't depend on where the body came from.
/// Identical to [`PathbaseExportArgs`] minus the `input` field — the body
/// is supplied by the caller (read from cache, derived in memory, …).
#[cfg(not(target_os = "emscripten"))]
#[derive(Debug)]
pub(crate) struct PathbaseUploadArgs {
pub(crate) url: Option<String>,
pub(crate) anon: bool,
pub(crate) repo: Option<RepoSpec>,
pub(crate) slug: Option<String>,
pub(crate) public: bool,
}

fn run_claude(input: String, project: Option<PathBuf>, output: Option<PathBuf>) -> Result<()> {
#[cfg(target_os = "emscripten")]
{
Expand Down Expand Up @@ -1208,124 +1221,133 @@ fn run_pathbase(args: PathbaseExportArgs) -> Result<()> {

#[cfg(not(target_os = "emscripten"))]
{
use crate::cmd_pathbase::{
anon_paths_post, api_me, credentials_path, load_session, paths_post, repos_post,
resolve_url,
};
use crate::cmd_pathbase::preflight_auth;

let file = cache_ref(&args.input)?;
let body = std::fs::read_to_string(&file)
.with_context(|| format!("Failed to read {}", file.display()))?;
// Validate locally so we give a clean error rather than relying on
// the server to reject malformed payloads.
let doc = toolpath::v1::Graph::from_json(&body)
.map_err(|e| anyhow::anyhow!("Invalid toolpath document: {}", e))?;

let stored = load_session(&credentials_path()?)?;
let base_url = match (&args.url, &stored) {
(Some(u), _) => resolve_url(Some(u.clone())),
(None, Some(s)) => s.url.clone(),
(None, None) => resolve_url(None),
let upload = PathbaseUploadArgs {
url: args.url,
anon: args.anon,
repo: args.repo,
slug: args.slug,
public: args.public,
};
let base_url = resolve_upload_base_url(&upload);
let needs_auth = upload.repo.is_some() || upload.public || upload.slug.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)
}
}

// Anonymous mode: explicit --anon, or no credentials at all and no
// override flags steering us toward an authed endpoint.
let go_anon = args.anon || (stored.is_none() && args.repo.is_none() && args.slug.is_none());
/// Resolve the upload target URL from the CLI flag, the stored session,
/// or the default. Mirrors the order used inside `run_pathbase_inner` so
/// `cmd_share`'s pre-flight resolution agrees with the eventual upload.
#[cfg(not(target_os = "emscripten"))]
pub(crate) fn resolve_upload_base_url(args: &PathbaseUploadArgs) -> String {
use crate::cmd_pathbase::{credentials_path, load_session, resolve_url};

if go_anon {
if !args.anon && stored.is_none() {
eprintln!(
"note: not logged in — uploading anonymously (not listable). Run `path auth login --url {base_url}` for a listable upload."
);
}
let resp = anon_paths_post(&base_url, &body)?;
// Server returns either a full URL or a path-only string; in the
// latter case prefix the base so the user gets a clickable link.
if let Some(u) = &args.url {
return resolve_url(Some(u.clone()));
}
if let Ok(path) = credentials_path()
&& let Ok(Some(s)) = load_session(&path)
{
return s.url;
}
resolve_url(None)
}

#[cfg(not(target_os = "emscripten"))]
pub(crate) fn run_pathbase_inner(
auth: crate::cmd_pathbase::AuthMode,
base_url: String,
args: PathbaseUploadArgs,
body: &str,
summary_source: &str,
) -> Result<()> {
use crate::cmd_pathbase::{AuthMode, anon_paths_post, paths_post, repos_post};

// Validate locally so we give a clean error rather than relying on
// the server to reject malformed payloads.
let doc = toolpath::v1::Graph::from_json(body)
.map_err(|e| anyhow::anyhow!("Invalid toolpath document: {}", e))?;

let (token, username) = match auth {
AuthMode::Anon => {
let resp = anon_paths_post(&base_url, body)?;
let printable = if resp.url.starts_with("http://") || resp.url.starts_with("https://") {
resp.url.clone()
} else if resp.url.starts_with('/') {
format!("{base_url}{}", resp.url)
} else {
format!("{base_url}/{}", resp.url)
};
println!("{printable}");
// Summary first on stderr, then the URL on stdout — the
// share URL is the primary product, so it's the last line
// the user (or a script piping the output) sees.
eprintln!(
"Uploaded {} → anon path {} ({} bytes)",
file.display(),
summary_source,
resp.id,
body.len()
);
println!("{printable}");
return Ok(());
}
AuthMode::Authed { token, username } => (token, username),
};

let session = stored.ok_or_else(|| {
anyhow::anyhow!("Not logged in. Run `path auth login` or pass `--anon`.")
})?;
if host_of(&base_url) != host_of(&session.url) {
eprintln!(
"warning: uploading to {} with a token issued by {}; expect 401 unless this is the same deployment",
base_url, session.url
);
let (owner, repo) = match args.repo {
Some(spec) => (spec.owner, spec.name),
None => {
// Pathstash default: own the repo "pathstash" under the username
// we resolved during preflight. Create it on demand.
repos_post(&base_url, &token, "pathstash")?;
(username, "pathstash".to_string())
}
};

let (owner, repo) = match args.repo {
Some(spec) => (spec.owner, spec.name),
None => {
// Pathstash default: own the repo "pathstash" under our username,
// creating it on demand. api_me is the source of truth for the
// username (display name in stored.user can drift).
let user = api_me(&base_url, &session.token)?;
repos_post(&base_url, &session.token, "pathstash")?;
(user.username, "pathstash".to_string())
}
};
let slug = args.slug.unwrap_or_else(|| derive_slug(&doc));
let created = paths_post(&base_url, &token, &owner, &repo, &slug, body, args.public)?;

let slug = args.slug.unwrap_or_else(|| derive_slug(&doc));
let created = paths_post(
&base_url,
&session.token,
&owner,
&repo,
&slug,
&body,
args.public,
)?;

// The visibility we surface is what the server actually applied,
// not what we requested. If a server-side policy ever clamps
// `is_public` (rate limits, account flags, future feature flags),
// we render the URL form the path can actually be reached at.
if created.is_public != args.public {
eprintln!(
"note: requested is_public={} but server applied is_public={}",
args.public, created.is_public
);
}
let visibility = if created.is_public {
"public"
} else {
"secret"
};
let url = pathbase_share_url(
&base_url,
&owner,
&repo,
&created.slug,
&created.id,
created.is_public,
);
println!("{url}");
// The visibility we surface is what the server actually applied,
// not what we requested. If a server-side policy ever clamps
// `is_public` (rate limits, account flags, future feature flags),
// we render the URL form the path can actually be reached at.
if created.is_public != args.public {
eprintln!(
"Uploaded {} → {}/{}/{} ({} path, {} bytes)",
file.display(),
owner,
repo,
created.slug,
visibility,
body.len()
"note: requested is_public={} but server applied is_public={}",
args.public, created.is_public
);
Ok(())
}
let visibility = if created.is_public {
"public"
} else {
"secret"
};
let url = pathbase_share_url(
&base_url,
&owner,
&repo,
&created.slug,
&created.id,
created.is_public,
);
// Summary first on stderr, URL last on stdout — same ordering as
// the anon path so the share URL is consistently the final line.
eprintln!(
"Uploaded {} → {}/{}/{} ({} path, {} bytes)",
summary_source,
owner,
repo,
created.slug,
visibility,
body.len()
);
println!("{url}");
Ok(())
}

/// Pick the canonical share URL for a path uploaded via `export pathbase`.
Expand Down Expand Up @@ -1392,21 +1414,6 @@ fn derive_slug(doc: &toolpath::v1::Graph) -> String {
format!("path-{}", &hex[..12])
}

/// Extract `scheme://host[:port]` from a URL, dropping any path/query.
/// Returns the input unchanged if it doesn't look like a URL.
#[cfg(not(target_os = "emscripten"))]
fn host_of(url: &str) -> &str {
let after_scheme = match url.find("://") {
Some(i) => i + 3,
None => return url,
};
// Find the next `/` after the scheme://; everything before it is host[:port].
match url[after_scheme..].find('/') {
Some(off) => &url[..after_scheme + off],
None => url,
}
}

#[cfg(all(test, not(target_os = "emscripten")))]
mod tests {
use super::*;
Expand Down Expand Up @@ -1550,21 +1557,6 @@ mod tests {
assert!(err.to_string().contains("parse") || err.to_string().contains("Failed"));
}

#[test]
fn host_of_strips_path() {
assert_eq!(host_of("https://pathbase.dev"), "https://pathbase.dev");
assert_eq!(host_of("https://pathbase.dev/"), "https://pathbase.dev");
assert_eq!(
host_of("https://pathbase.dev/api/v1/traces"),
"https://pathbase.dev"
);
assert_eq!(
host_of("http://127.0.0.1:9000/foo"),
"http://127.0.0.1:9000"
);
assert_eq!(host_of("not-a-url"), "not-a-url");
}

#[test]
fn gemini_writes_resume_ready_layout() {
// End-to-end: a path doc whose conversation.append carries a
Expand Down
Loading
Loading