diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f143f2d..8a56926d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,137 @@ All notable changes to the Toolpath workspace are documented here. +## Share and resume anywhere: S3, a folder, or Pathbase — 2026-08-10 + +**`path-cli`** (0.17.0) makes the share destination configurable. Bare +`path share` still goes to Pathbase by default, but it can now be +pointed at an S3 bucket or a plain folder once, and stay there — and +what you share to is somewhere you can browse and resume out of, not a +write-only hole. + +**Designating a target.** `path target ` sets it, and +`path target` with no argument prints what's in effect and why: + +``` +path target ~/Dropbox/toolpath-traces # a folder — no credentials needed +path target s3://my-bucket/traces +path target pathbase # switch back +path target --clear +``` + +Setting a target **verifies** it, by doing what a share does: writing a +small object and removing it. That's the whole point of checking at +configuration time — a target is set once and used many times, so a +wrong one that survives costs a session pick and a derivation later, at +the moment the user wanted a result rather than a setup step. A listing +would only tell you about `s3:ListBucket`, which isn't the permission a +share needs; only a write answers the question actually being asked. +Folders go through the same path, which also creates them. `--no-verify` +stores a target unchecked, for a bucket that doesn't exist yet or a +laptop that's offline. + +It's a top-level verb rather than a flag on `share` or a subcommand of +`auth`, because it's a persistent setting and it is not authentication. +The value is stored as `default_target` in `~/.toolpath/config.json` — +one place, so "where does my next share go?" has one answer. + +A scheme-less value is a **local path**; spell a bucket `s3://…`. A +*bare relative* value (`my-bucket/traces`) is rejected outright rather +than quietly creating `./my-bucket/traces` and reporting success — +`./my-bucket/traces` says you meant it. Folder targets are stored as +`file://` URLs, so a stored default can't drift with the working +directory, but they're displayed and printed as plain paths. +Resolution: `--to`, then `$TOOLPATH_SHARE_TARGET`, then the stored +default, then Pathbase. + +Nothing is inferred from which credentials happen to exist: a share +that silently changes destination is a data-egress bug, not a +convenience. The one guard is at the bottom of the order — if S3 +credentials are stored, no Pathbase session exists, and no target is +set, `path share` refuses rather than falling through to the +*anonymous public* Pathbase endpoint. + +**Legible object names.** A shared document lands at +`--.json` — e.g. +`2026-08-07-add-s3-support-to-share-claude-6f2a1c9e.json`. Every +component is a pure function of the document, so re-sharing a session +that has grown overwrites its own object instead of leaving a trail of +near-duplicates; the date comes from the session's *first* step, so it +doesn't move as the conversation continues. The point is that a +destination is a folder someone will open or a bucket someone will page +through, and it also makes listing cheap enough to build a picker on. + +**Resuming from a destination.** `path resume ` — a +bucket, a prefix, or a folder — lists what's there and offers a picker, +the counterpart to `path share`'s picker across every harness. Rows are +built from object names alone, so browsing a hundred shared sessions +costs one list request and zero downloads. A destination holding a +single document skips the picker. Anything ending in `.json` is still +treated as a document and fetched directly. + +**Per-call override.** `path share --to ` takes the same forms. +The Pathbase-only flags (`--anon`, `--repo`, `--public`, `--url`, +`--name`) select Pathbase on their own, overriding an object target; +combining one with an explicit object `--to` is an error rather than a +silent resolution. The target is resolved before the harness scan and +the picker, and an `s3://` target is probed for reachability before the +picker too — a weaker check than `path target`'s, since the upload is +about to happen and will report its own failure; all it needs to buy is +not wasting a derivation on a typo'd bucket. Both checks bound their own +runtime (short timeouts *and* a retry cap, since the default ten retries +would otherwise multiply the timeout). + +**Resume by location.** `path resume s3://bucket/key.json` caches +downloads under an `s3--` id, so `--force` and +`--no-cache` behave exactly as they do for Pathbase. A document shared +to a folder is resumed with its plain path, which already worked. + +**S3 credentials come from wherever you already keep them.** If you use +the AWS CLI, `path` needs no configuration at all: `~/.aws/credentials` +and `~/.aws/config` are read directly, `AWS_PROFILE` and `--profile` +select a profile, and the profile's region is used if you haven't set +one. SSO, `role_arn` chains, and `credential_process` profiles are +resolved by shelling out to `aws configure export-credentials` — the +AWS CLI's own resolver, so refresh and every future profile type stay +its problem rather than ours. + +`object_store` alone would have covered only the server cases (EC2, ECS, +EKS); it reads no `~/.aws` because it avoids the AWS SDK. Taking on +`aws-config` to fix that would have meant 31 crates and an MSRV +treadmill — its family requires rustc 1.94.1 against a repo pinned to +1.94.0. + +`path auth s3 status` now reports *which* credential source won, because +that's the first question when an upload fails. + +**Storing keys is now the fallback, not the happy path.** `path auth s3 +login` is for endpoints AWS tooling doesn't know about — MinIO, R2, Ceph +— where a scoped long-lived token is the right answer. It stores region, +endpoint, +addressing style, an optional `profile` name, and credentials at +`~/.toolpath/s3.json` (0600) — connection only, deliberately not a destination, so one stored +credential serves any number of buckets. It merges rather than +replaces, so `path auth s3 login --region eu-west-1` is a valid tweak; +run it bare in a terminal and it prompts, without echoing the secret. +`path auth s3 status` prints the settings in effect with secrets +redacted and environment-supplied values marked `(env)`. Stored values +win over the environment (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +`AWS_SESSION_TOKEN`, `AWS_REGION`, `AWS_ENDPOINT_URL_S3`); with +neither, the AWS credential chain still applies, so an EC2 instance +role needs no configuration at all. + +**Plumbing.** `path p export object --input [--to DEST]` and +`path p import object `, both aliased `s3`. With no `--to`, +export writes wherever `path share` would. + +Transport is the `object_store` crate, so one code path covers AWS S3, +any S3-compatible endpoint (R2, MinIO, Ceph, B2 — point `--endpoint` at +it), and `file://` for a folder. `memory://` is rejected: it's a fresh +per-process store, so anything "shared" there is gone before the +command exits. The folder backend is what the tests round-trip against, +so share and resume are exercised end-to-end without a network or a +mock HTTP server. + ## Projected Claude sessions are resumable again — 2026-07-30 Two fixes found by live-resuming a projected session against the real diff --git a/CLAUDE.md b/CLAUDE.md index 7fb2d744..9c820d69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,21 +112,32 @@ cargo run -p path-cli -- p import opencode --session ses_ cargo run -p path-cli -- p import cursor --session # IDE composer from state.vscdb cargo run -p path-cli -- p import pi --project /path/to/project cargo run -p path-cli -- p import pathbase +cargo run -p path-cli -- p import object s3://my-bucket/traces/claude-abc.json cargo run -p path-cli -- p import claude --project . --no-cache | path p render md --input - -# Share an agent session to Pathbase (interactive picker, single-shot) +# Share an agent session (interactive picker, single-shot). Bare `share` +# goes wherever `path target` points — Pathbase unless changed. 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 --url https://my-pathbase.example +# Override the target for one call: a bucket, a folder, or Pathbase +cargo run -p path-cli -- share --to s3://my-bucket/traces +cargo run -p path-cli -- share --to ~/Dropbox/toolpath-traces +cargo run -p path-cli -- share --to pathbase # force Pathbase for one call + # Resume a Toolpath document into your coding agent of choice (interactive harness picker) -cargo run -p path-cli -- resume +cargo run -p path-cli -- resume +cargo run -p path-cli -- resume s3://my-bucket/traces/2026-08-07-add-s3-support-claude-abc.json +cargo run -p path-cli -- resume s3://my-bucket/traces # a destination: lists it, picker +cargo run -p path-cli -- resume ~/Dropbox/toolpath-traces cargo run -p path-cli -- resume --harness claude -C /path/to/project # Plumbing: export toolpath documents into external formats. is a # cache id or a file path. cargo run -p path-cli -- p export claude --input --project /tmp/sandbox cargo run -p path-cli -- p export claude --input --output conv.jsonl +cargo run -p path-cli -- p export object --input --to s3://my-bucket/traces cargo run -p path-cli -- p export cursor --input --project /tmp/workspace # writes composer rows into state.vscdb cargo run -p path-cli -- p export cursor --input --output composer.json cargo run -p path-cli -- p export pathbase --input @@ -168,6 +179,12 @@ cargo run -p path-cli -- auth login cargo run -p path-cli -- auth status cargo run -p path-cli -- auth whoami cargo run -p path-cli -- auth logout +cargo run -p path-cli -- target s3://my-bucket/traces # verified before it's stored +cargo run -p path-cli -- target ~/Dropbox/traces # a folder needs no credentials +cargo run -p path-cli -- target # print what's in effect, and why +cargo run -p path-cli -- auth s3 login --access-key-id AKIA... # credentials only +cargo run -p path-cli -- auth s3 status +cargo run -p path-cli -- auth s3 logout ``` **Breaking** (pre-1.0). The previous top-level commands `path import`, @@ -177,7 +194,7 @@ cargo run -p path-cli -- auth logout and no deprecation shim. They all now live exclusively under `path p …`. -The **cache** at `~/.toolpath/documents/.json` is the single landing zone for every `import` (and for `import pathbase` downloads). Cache id is `-` — e.g. `claude-abc123`, `git-main`, `pathbase-alex-pathstash-path-pr-42` (Pathbase paths key on `--`, anon paths on `anon-pathstash-`). Files are `0600`, parent directory `0700`. `$TOOLPATH_CONFIG_DIR` overrides the root. Default behavior: error on cache hit; pass `--force` to overwrite. `--no-cache` sends the JSON to stdout for shell composition. `p cache sync` fills the cache incrementally from the installed agent harnesses (see "Things to know") and always overwrites what it re-derives. +The **cache** at `~/.toolpath/documents/.json` is the single landing zone for every `import` (and for `import pathbase` downloads). Cache id is `-` — e.g. `claude-abc123`, `git-main`, `pathbase-alex-pathstash-path-pr-42` (Pathbase paths key on `--`, anon paths on `anon-pathstash-`), `s3-my-bucket-traces_claude-abc` (object downloads key on `-` with slashes flattened; `s3a://` folds into the same `s3` prefix so an alias can't fork the cache). Files are `0600`, parent directory `0700`. `$TOOLPATH_CONFIG_DIR` overrides the root. Default behavior: error on cache hit; pass `--force` to overwrite. `--no-cache` sends the JSON to stdout for shell composition. `p cache sync` fills the cache incrementally from the installed agent harnesses (see "Things to know") and always overwrites what it re-derives. `path auth login` prints `/auth/cli`; the user opens it, logs in, and pastes the 8-character code back into the CLI. The CLI calls @@ -187,6 +204,79 @@ writes to `~/.toolpath/credentials.json` (0600, parent dir 0700) and sends as overrides the credentials directory. Server URL comes from `--url`, then `$PATHBASE_URL`, then `https://pathbase.dev`. +**Where shares go** is one setting in one place: `default_target` in +`~/.toolpath/config.json`, set by `path target ` (a top-level verb, +not a flag on `share` and not under `auth` — it's a persistent setting and +it isn't authentication) and read by `crate::target`. A target is the literal `pathbase`, or an object-storage +location — an S3 bucket (`s3://bucket/prefix`) or a plain folder +(`~/Dropbox/traces`, `/srv/traces`, `file:///srv/traces`). **A scheme-less +value is a local path, not a bucket**: bare strings are what people type +when designating a folder, and a bucket is unambiguous when spelled +`s3://`. A *bare relative* value (`my-bucket/traces`) is **rejected** — it's +overwhelmingly a bucket name typed from memory, and resolving it against the +cwd would create `./my-bucket/traces` and report success; `./my-bucket/traces` +opts in explicitly. A *bare relative* value (`my-bucket/traces`) is **rejected** — +it's overwhelmingly a bucket name typed from memory, and resolving it +against the cwd would create `./my-bucket/traces` and report success; +`./my-bucket/traces` opts in explicitly. Folder targets are persisted as `file://` URLs (so a stored +default can't be re-read relative to a different cwd) but displayed and +printed as plain paths (so the printed location pastes straight into +`path resume`, which already accepts file paths). Resolution order: +`--to` → `$TOOLPATH_SHARE_TARGET` → `default_target` → Pathbase. +Setting a target **verifies** it first (`target::verify` → `store::verify`): a real write of a `.toolpath-access-check` object, then a delete. Configuration time is the right time to fail — a target is set once and used many times, and a listing would only prove `s3:ListBucket`, not the `s3:PutObject` a share needs. Folders go through the same path, which also creates them; a credential that can write but not delete leaves the probe behind and gets a note, not a failure. `--no-verify` stores unchecked (bucket not created yet, offline). `path target` with no argument prints the stored value, what is +actually in effect, and which of those four supplied it. + +Nothing is inferred from which credentials happen to exist — a share that +silently changes destination is a data-egress bug, not a convenience — with +one guard at the bottom of the order: if S3 credentials are stored, there's +no Pathbase session, and no default is set, share **refuses** rather than +falling through to the anonymous public Pathbase endpoint. Status commands +use `target::describe_effective`, which never inherits that refusal. + +**S3 credentials** are resolved by `crate::aws_creds`, not by `object_store` +alone. `object_store` covers the *server* cases (EKS/IRSA web identity, ECS +task roles, EC2 instance metadata) and deliberately reads no `~/.aws` at all, +because it avoids depending on the AWS SDK — which leaves out how nearly every +developer actually has S3 access. `aws_creds` fills that in: static-key +profiles are parsed straight out of `~/.aws/credentials` (trivial ini, no +deps), and anything else — SSO, `role_arn` chains, `credential_process` — is +delegated to `aws configure export-credentials --format process`, which runs +the AWS CLI's own resolver. Anyone using SSO already has the CLI (`aws sso +login` is how they authenticate), and delegating keeps refresh, cache layout, +and future profile types the CLI's problem. Depending on `aws-config` instead +would be 31 crates *and* an MSRV treadmill: its whole family currently requires +rustc 1.94.1 while `rust-toolchain.toml` pins 1.94.0. + +Precedence (AWS's own, with our stored settings layered on top): `path auth s3 +login` → `--profile` / `$AWS_PROFILE` → `AWS_ACCESS_KEY_ID` → the `[default]` +profile → `object_store`'s instance chain. Region falls back to the profile's, +so `~/.aws/config` doesn't have to be retyped. `path auth s3 status` prints +*which* source won — the first question when an upload fails is always which +credential was tried, and a stored key, a profile, and nothing at all are three +different fixes. `AWS_SHARED_CREDENTIALS_FILE` / `AWS_CONFIG_FILE` are honored, +which is also how the integration tests stay off a developer's real profiles. + +`path auth s3 login` stores S3 *connection* settings at +`~/.toolpath/s3.json` (0600, parent dir 0700, same `$TOOLPATH_CONFIG_DIR` +root): region, endpoint, access key id, secret access key, session token, an +optional AWS `profile` name, and an optional virtual-hosted-style toggle. +Storing a *profile name* is very different from storing a key — it's a pointer +to credentials the AWS tooling already manages, so it can't go stale and costs +nothing at rest. The file is deliberately **not** a destination — that's the +share target — so one stored credential serves any number of buckets, and a +folder target needs no `auth s3` at all. It +**merges** into what's already stored — only the fields you pass change — +so `path auth s3 login --region eu-west-1` is a valid tweak; run it bare in +a terminal with nothing stored yet and it prompts, reading the secret +without echoing it. Stored values take precedence over the environment +(`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`, +`AWS_REGION`/`AWS_DEFAULT_REGION`, +`AWS_ENDPOINT_URL_S3`/`AWS_ENDPOINT_URL`); with neither, `object_store` +falls through to the AWS credential chain (instance metadata, web +identity), so an EC2 box with a role needs no configuration. +`path auth s3 status` prints the effective settings with secrets redacted +and env-supplied values marked `(env)`. + The CLI redeem endpoint (`POST /api/v1/auth/cli/redeem`) is real and works in production but is **not listed in `schema/pathbase-openapi.json`** — the OpenAPI spec only covers the documented surface. Don't be surprised that @@ -218,7 +308,7 @@ Tests live alongside the code (`#[cfg(test)] mod tests`), plus `path-cli` has in - `toolpath-cursor`: 78 unit + 8 integration round-trip + 1 real-DB sanity + 1 doc test (state.vscdb SQLite reader, bubble store + composer header parsing, content-addressed blob lookup, projector with full TOOL_TABLE coverage, JSONL transcript ingest in `examples/dump_fixture.rs`) - `toolpath-pi`: 133 unit + 26 integration + 5 doc tests (types, paths, error, reader, io, provider) - `toolpath-dot`: 30 unit + 2 doc tests (render, visual conventions, escaping) -- `path-cli`: 358 unit + 121 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. +- `path-cli`: 429 unit + 154 integration tests (import/export/cache, track sessions, merge, validate, roundtrip, render-md snapshots, deprecation aliases, pathbase HTTP mock-server tests, share targets over a local-folder backend — `path target` set/print/clear/env-precedence + the bare-relative rejection + write-verification at configuration time (unreachable bucket refused and not stored, `--no-verify` escape hatch, folder created with the probe cleaned up, unwritable folder refused), legible-name stability across re-shares, `auth s3 login/status/logout`, AWS credential resolution (profile precedence, SSO delegated to a stubbed AWS CLI, static profiles never shelling out, unknown-profile errors, ini edge cases), `p export object` → `p import object` round trip, Pathbase-flag interaction, resume-from-object-storage with cache/`--no-cache`/missing-object cases, and resume-from-a-destination (single-document short-circuit, empty-destination guidance, container-URL detection) — fzf-friendly TSV output, `path resume` orchestration with injectable `ExecStrategy`, `path query`/`path kind` jaq filters + kind-selector matching + step wrapping over a `$TOOLPATH_CONFIG_DIR` cache sandbox, streaming-planner recognition + streamed-output-equals-slurp equality checks, `p cache sync` incremental ingestion — stat fingerprints, refresh-overwrite, per-type filtering, failure tallying — over resolver-injected provider fixtures, and import/share manifest recording end-to-end). For an end-to-end check against a real Pathbase deployment, run `scripts/test-pathbase-live.sh ` — it does an anon round-trip in a sandboxed config dir and, if you're logged into that URL, an authed pathstash round-trip too. - `toolpath-cli`: 0 tests (it's a one-line `path_cli::run()` shim crate that exists only so `cargo install toolpath-cli` keeps installing the `path` binary) Validate example documents: `for f in examples/*.json; do cargo run -p path-cli -- p validate --input "$f"; done` @@ -285,9 +375,10 @@ Build the site after changes: `cd site && pnpm run build` (should produce 11 pag - 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 p import ` (claude / gemini / pi / codex / opencode) auto-launches a fuzzy picker when stdin and stderr are TTYs and no `--session` was given. Backend: external `fzf` if on `$PATH`, otherwise the embedded skim picker (default-feature `embedded-picker`, defined in `crates/path-cli/src/skim_picker.rs`). Multi-select (TAB) produces a `Graph` document; single-select produces a `Path`. The picker uses `path show --…` as its `--preview` command. When neither backend can run (no TTY, or `--no-default-features` AND no `fzf`), it falls back to most-recent (with `--project`) or prints the manual recipe (without). `path p list --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` — 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 p import | path p 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. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. -- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; resolves it (caching URL fetches under `~/.toolpath/documents/` unless `--no-cache`); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. +- `path share` is the one-shot equivalent of `path p import | path p 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`. `--to ` overrides the destination for one call — `pathbase`, an S3 bucket, or a folder (see "Where shares go" above). The Pathbase-only flags select Pathbase on their own, overriding an object-storage default; combining one with an explicit object `--to` is an error rather than a silent resolution. Target selection is split in two: `cmd_share::plan_target` is pure (config read only, no network) and runs **first**, before the harness scan and the picker, so a typo'd `--to` or a flag conflict costs nothing to discover; `cmd_share::open_target` then probes — a Pathbase auth preflight, or for `s3://` a `list_with_delimiter` reachability check via `Destination::probe` — before the picker fires, so neither a bad credential nor a typo'd bucket wastes the derive/cache work. Share's probe is deliberately weaker than `path target`'s write check: the upload is about to happen and will report its own failure, so all it needs to buy is not wasting a derivation, and a write-only credential legitimately can't list. Both checks go through `store::open_for_check`, which caps timeouts *and* retries — `object_store`'s default ten retries would otherwise multiply a 5s connect timeout into a ~50s stall, and its retry epilogue ("after 10 retries, max_retries: …") is stripped from user-facing errors by `store::terse`. By default the derived doc is written to the cache like `import` does; pass `--no-cache` to skip. When the manifest shows the picked session unchanged since its last sync (`sync::fresh_cache_id`: stamps match, doc present; the freshness stat targets that one artifact directly, no sibling enumeration), share uploads the cached doc directly instead of re-deriving. The cache ingests maximally (thinking always included), and uploads carry the same full derivation as local projection (`resume`, `p export `) — there is no egress stripping. +- `path resume ` is the inverse of `path share`. It accepts a Pathbase URL, an object-storage URL (`s3://bucket/key.json`, also `s3a://`/`file://`), a **share destination** — a bucket, prefix, or folder, which it lists and offers in an fzf picker built from object names alone (no downloads; a single-document destination skips the picker) — an `owner/repo/slug` shorthand, a local toolpath JSON file, or a cache id; a shared document is always `.json`, which is how a document is told from a place to browse; resolves it (caching remote fetches under `~/.toolpath/documents/` unless `--no-cache`, via the shared `cmd_resume::fetch_cached` — every remote shape computes its cache id from the reference alone, so a cache hit costs no round trip); validates that the document is a single agent-bearing `Path`; then opens an `fzf` harness picker (skipped with `--harness X`). The picker pre-selects the source harness inferred from `path.meta.source` (`claude-code`/`gemini-cli`/`codex`/`opencode`/`pi`) when it's installed. After picking, `path resume` projects the session into the harness's on-disk layout under the chosen working directory (default: shell cwd; override with `-C, --cwd P`) and `execvp`'s the harness's resume command (`claude -r ` / `gemini --resume ` / `codex resume ` / `opencode --session ` / `pi --session `). On Windows it spawns and waits, propagating the exit code. The exec is mockable via `cmd_resume::ExecStrategy` — production uses `RealExec`; integration tests use `RecordingExec` to capture the recipe without launching a real harness. - `path query` does not load the whole cache into memory when it can avoid it. `crates/path-cli/src/query/plan.rs` parses the jaq filter into jaq's own AST (`jaq_core::load::parse::Term`) and classifies it into a `Plan`: `PerFileStream` (`.[] | g` element-wise work — run per document, print as you go), `Decompose { reduce }` (algebraic aggregations — run the whole filter per file, concatenate the per-file outputs, then run a derived combine: `map`→`add` (array concat), top-N `sort_by(k)|.[:N]`→`add | sort_by(k)|.[:N]`, `length`→`add` over exact integer counts), or `Slurp` (the always-correct whole-array fallback). Recognition is conservative — a non-distributive prefix like `unique`/`group_by` slurps, and so do scalar `add` (float sums re-associate across per-file partials), `min`/`max` (`[] | min == null` poisons the merge), and any unrecognized tail — so **the planner never changes an answer** — `crates/path-cli/src/query/filter.rs` tests assert streamed output equals slurp byte-for-byte. `filter::execute` compiles the filter once (jaq's compiled `Filter` is fully owned, so it's reused across files) and drives the plan; `mod.rs::stream_files` yields one document's wrapped steps at a time. `TOOLPATH_QUERY_EXPLAIN=1` prints the chosen plan to stderr. No user-facing flag — it's automatic. Tie-break caveat: a streamed top-N matches slurp's *ranking*, but boundary ties may resolve to different specific rows. - Cache sync: `path p cache sync [types…]` (`crates/path-cli/src/artifact.rs`: `ArtifactType` + `ArtifactRef` + the stamp helpers; `sync/engine.rs`: manifest + ingestion loop, no UI — it reports through a `SyncObserver` trait, `&mut ()` for a silent sync; `sync/sources.rs`: an `ArtifactSource` trait — enumerate / stamp / derive / peek-dir / scope-match — with one impl per provider, so the engine never matches on artifact type; `cmd_cache.rs`: the stderr progress line + summary) incrementally ingests artifacts into the cache — no args syncs every artifact type. Change detection is **stat-level**: each artifact is enumerated as an `ArtifactRef` whose fingerprint is the source file's mtime + size (claude: the *whole session chain* — max segment mtime + summed segment sizes via `claude_chain_stamp`, because Claude Code rotates to a new file on continuation while the chain keeps its oldest segment's id, so appends land in the newest file, not the head; the chain comes from the same cached index `list_conversations` builds; codex: rollout file, id from the stem's trailing UUID; pi: session file, id from a one-line header peek; copilot: `session-state//events.jsonl`, pure read-dir + stat) or the DB row's updated-at (opencode: header-only `SELECT time_updated`; cursor: composer headers' `lastUpdatedAt`, bubble-less drafts skipped, workspace-less composers *included* unlike `share`). Gemini enumerates via `PathResolver::list_session_entries` (`toolpath-gemini` 0.6.1), whose identity peek is bounded to the first 4 KiB of a main file. Deciding "nothing changed" reads no session bodies — a no-op sync is milliseconds. Changed/new artifacts derive through the same provider managers (each source calls the `derive_*_session_with` helpers in `derive.rs`). Manifest at `~/.toolpath/manifest.json`: artifact type → artifact id → `{path?, cache_id, modified?, size?, synced_at}`; atomic temp+rename writes, `0600`, checkpointed every 10 writes (interruption-safe: a killed run keeps nearly everything it derived, and derives run newest-first so partial progress covers the sessions that matter most); writers serialize on an advisory lock (`manifest.json.lock`) and every write is a locked read-merge-save — checkpoints merge only the records the run wrote — so concurrent invocations (query auto-syncs, imports) union their records instead of clobbering each other. Pending work reports progress on stderr (`\r`-updating ` done/total` on a TTY, a plain line every 25 items otherwise; no-op syncs stay silent). Sync always writes the cache with force — 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's `cache_id` is *optional*: a record without one is "known, not materialized" — created when a `--project-under` constraint excluded a peeked artifact, or when `p cache rm` evicts a doc (rm downgrades the record; the next in-scope sync re-materializes it, and sync also verifies the doc file actually exists before skipping, so even out-of-band deletions self-heal). `--project-under ` on both `p cache sync` and `path query` restricts ingestion to sessions whose project directory (recorded cwd) is under that directory (subtree): path-keyed providers prune whole projects before enumerating (claude compares in *slug space* — its dir slugs are lossy, `/`/`_`/`.` all became `-`), cwd-keyed ones check the directory their cheap headers carry, and codex/copilot — whose cwd lives inside the session file — get a one-line peek only when new/changed, memoized into the record's `path`. The stat gate always runs first: unchanged+cached artifacts skip before any scope check. Out-of-scope work is tallied separately (`N out of scope`) and never touches a materialized record's stamp. Claude derives leave `DeriveConfig.project_path` unset so `path.base` comes from the session's own recorded cwd rather than the lossy slug. `path query` runs this sync implicitly before reading, scoped to its flags (`--source X` → that type; `--id`s → their prefixes; bare query → all types; `--input`-only → none), quiet unless something was ingested, degrading to the cache as-is if sync fails; `--no-sync` opts out. `p import` and `share` record what they write: every session derive carries a provenance `ArtifactRef` (stamped *before* the source is read, in `DerivedDoc.provenance`), and the cache-write sites call `sync::record_artifact` so the next sync sees those artifacts as unchanged instead of re-deriving them. Every import flow — explicit `--session`, picker multi-select, `--all`, and the most-recent fallbacks — loops the per-session helpers, so every session write is recorded; there is no bulk `derive_project` path in the CLI anymore, and `p import pi --all` now emits one Path per session like every other provider (it used to emit a single combined Graph). `--no-cache` paths record nothing: the manifest describes the cache. +- Object-storage transport: `crates/path-cli/src/store.rs` splits *where* from *how to authenticate* — `Destination`/`ObjectUri` (pure URL parsing and key layout, no credentials) and `S3Settings` (`~/.toolpath/s3.json`, env merge, `get`/`put`). `crates/path-cli/src/target.rs` sits above it and owns the Pathbase-vs-object choice plus the stored default. Transport is the `object_store` crate, so one code path serves AWS S3, any S3-compatible endpoint (R2, MinIO, Ceph, B2 — set `endpoint`), and `file://` for a local directory; `object_store` is async, so it tunnels through the same `cmd_pathbase::block_on` runtime the Pathbase client uses. `file://` is a first-class destination, not a toy — `path target ~/traces` is a complete setup needing no credentials — and it's what the tests round-trip against, so share and resume are covered end-to-end with no network and no mock HTTP server. Accepted schemes are deliberately narrower than what `object_store` parses (`s3`, `s3a`, `file`) — `http`/`https` belong to Pathbase in the same dispatch, `gs://`/`az://` would need feature flags we don't compile in, and `memory://` is a fresh per-process store whose contents vanish before the command exits. Object names are `--.json` (`store::name_for`): every component is a pure function of the document, so a re-share overwrites its own object rather than leaving near-duplicates (the date is the *earliest* step's, so it doesn't move as the session grows), and the name is legible enough that `Destination::list` + `cmd_resume::pick_from_destination` can build picker rows without downloading anything. Config files under the config dir (Pathbase sessions, S3 settings, `config.json`) all go through `config::write_private_json` / `read_private_json`, which own the 0600-file / 0700-parent story. - Claude Code plugin: `.claude-plugin/marketplace.json` (marketplace `toolpath`) + `plugins/claude-code/` (plugin `path`, so commands are `/path:share` and `/path:query`). The plugin does **not** commit binaries — both commands invoke the CLI through `plugins/claude-code/scripts/ensure-path.sh`, which prefers an existing Toolpath `path` on PATH (identity-checked via `--help`), else `~/.local/bin/path`, else `~/.toolpath/bin/path`, else downloads the latest GitHub release (sha256-verified, same logic as `scripts/install.sh`) and installs globally to `~/.local/bin` — falling back to `~/.toolpath/bin` when a foreign binary named `path` claims the name. Two hard-won constraints baked into the command docs: slash-command inline `!` context commands and model-issued Bash must not contain `$PWD`/variables (Claude Code's permission checker rejects commands it can't statically analyze — hence the `sessions` and `current-session` helper modes, the latter reading `$CLAUDE_CODE_SESSION_ID` so no-arg `/path:share` shares exactly the running session), and `--project` must always be an absolute path (path-cli does not canonicalize relative `--project` values; `.` silently matches nothing). Tests: `scripts/test-plugin.sh` (manifest consistency + offline bootstrap tests against a stubbed curl/release), wired in as the `plugin` quality gate; plugin shell scripts are shellchecked. Dev loop: `claude --plugin-dir ./plugins/claude-code`. Future harness integrations go under `plugins//` (only Claude Code plugins are marketplace entries; other harnesses distribute their own way). Version bumps: keep `plugins/claude-code/.claude-plugin/plugin.json` and the matching entry in `.claude-plugin/marketplace.json` in lockstep (test-plugin.sh asserts this); the binary is unpinned (latest release) with `MIN_VERSION` in ensure-path.sh naming the oldest CLI the command docs support. -- `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github and pathbase are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. +- `ArtifactType` (`crates/path-cli/src/artifact.rs`) is the general enum naming artifact sources — the seven agent harnesses (incl. copilot) plus `Git` (8 variants). Git artifacts are *recorded* in the manifest by `p import git` (id `-`, `path` = the repo directory) but never *discovered* — there is no machine-wide registry of repos — so sync reports them and leaves them alone. Github, pathbase, and s3 are deliberately not artifact types: they are remote services, not local artifact sources, and their imports stay out of the manifest. It derives `clap::ValueEnum` and is used by `p cache sync` types, the sync manifest keys, `ArtifactRow.artifact_type`, and `cmd_import`'s cache-id prefixes (`name()` is both the manifest key and the `make_id` source string). The deliberately parallel `Harness` enum (`crates/path-cli/src/harness.rs`, alongside `HarnessBundle`) names the seven agent *runtimes* — things sessions can be shared from and resumed into — and is what `share`/`resume` `--harness` take, so future non-harness artifact types stay unrepresentable there (you can't resume into a git repo). `Harness::artifact_type()` maps into the general enum; `ArtifactType::harness()` is the partial inverse. Keep new code on `ArtifactType` unless it's genuinely harness-only. diff --git a/Cargo.lock b/Cargo.lock index d9b355b7..2c130fd2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -151,6 +151,17 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1685feee7d06d8813fe963f814c5c398d90392b9c3c41e656ac3100d5c334536" +[[package]] +name = "async-trait" +version = "0.1.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae36dc4177970ef04fde5178d3e2429882def40e57a451f919c098f72baa6cec" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + [[package]] name = "atomic" version = "0.6.1" @@ -269,6 +280,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", +] + [[package]] name = "borrow-or-share" version = "0.2.4" @@ -349,6 +369,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -543,6 +574,25 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc-fast" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e75b2483e97a5a7da73ac68a05b629f9c53cff58d8ed1c77866079e18b00dba5" +dependencies = [ + "digest 0.10.7", + "spin", +] + [[package]] name = "crossterm" version = "0.29.0" @@ -583,6 +633,15 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-common" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" +dependencies = [ + "hybrid-array", +] + [[package]] name = "csscolorparser" version = "0.6.2" @@ -781,8 +840,18 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", - "crypto-common", + "block-buffer 0.10.4", + "crypto-common 0.1.7", +] + +[[package]] +name = "digest" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", ] [[package]] @@ -1036,7 +1105,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ca1f7b0dce1c25254457f2edcfcf611bf207113564b96f90659a3f54506016b2" dependencies = [ - "itertools", + "itertools 0.14.0", "raw-cpuid", ] @@ -1189,6 +1258,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.1", "wasip2", "wasip3", ] @@ -1334,6 +1404,21 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "humantime" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" + +[[package]] +name = "hybrid-array" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "707114b52a152fa7bdb290cd7cd5912d9467273b6d74e21b8d81aca1f8533f6b" +dependencies = [ + "typenum", +] + [[package]] name = "hyper" version = "1.9.0" @@ -1661,6 +1746,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -2065,6 +2159,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "md-5" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b6441f590336821bb897fb28fc622898ccceb1d6cea3fde5ea86b090c4de98" +dependencies = [ + "cfg-if", + "digest 0.11.3", +] + [[package]] name = "memchr" version = "2.8.0" @@ -2338,6 +2442,48 @@ dependencies = [ "memchr", ] +[[package]] +name = "object_store" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d354792e39fa5f0009e47623cf8b15b099bf9a652fa55c6f817fe28ac84fea50" +dependencies = [ + "async-trait", + "aws-lc-rs", + "base64", + "bytes", + "chrono", + "crc-fast", + "form_urlencoded", + "futures-channel", + "futures-core", + "futures-util", + "http", + "http-body-util", + "humantime", + "hyper", + "itertools 0.15.0", + "md-5", + "nix 0.31.3", + "parking_lot", + "percent-encoding", + "quick-xml", + "rand 0.10.2", + "reqwest", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "thiserror 2.0.18", + "tokio", + "tracing", + "url", + "walkdir", + "wasm-bindgen-futures", + "web-time", + "windows-sys 0.61.2", +] + [[package]] name = "once_cell" version = "1.21.4" @@ -2441,7 +2587,7 @@ dependencies = [ [[package]] name = "path-cli" -version = "0.16.1" +version = "0.17.0" dependencies = [ "anyhow", "assert_cmd", @@ -2454,11 +2600,13 @@ dependencies = [ "jaq-json", "jaq-std", "jsonschema", + "object_store", "pathbase-client", "predicates", "rand 0.9.4", "regex", "reqwest", + "rpassword", "rusqlite", "serde", "serde_json", @@ -2480,6 +2628,7 @@ dependencies = [ "toolpath-md", "toolpath-opencode", "toolpath-pi", + "url", "uuid", ] @@ -2817,6 +2966,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "quinn" version = "0.11.9" @@ -2913,6 +3072,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -2938,6 +3108,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "ratatui" version = "0.30.0" @@ -2962,7 +3138,7 @@ dependencies = [ "compact_str", "hashbrown 0.16.1", "indoc", - "itertools", + "itertools 0.14.0", "kasuari", "lru", "strum", @@ -3014,7 +3190,7 @@ dependencies = [ "hashbrown 0.16.1", "indoc", "instability", - "itertools", + "itertools 0.14.0", "line-clipping", "ratatui-core", "strum", @@ -3217,6 +3393,27 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rpassword" +version = "7.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2da316a15f47e3d053de9cb2c439650bd8fa4aaeb9365f2e5f27f492ff73c196" +dependencies = [ + "libc", + "rtoolbox", + "windows-sys 0.61.2", +] + +[[package]] +name = "rtoolbox" +version = "0.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a0e551c1e27e1731aba276dbeaeac73f53c7cd34d1bda485d02bd1e0f36844" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "rusqlite" version = "0.32.1" @@ -3550,8 +3747,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3561,8 +3758,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", - "digest", + "cpufeatures 0.2.17", + "digest 0.10.7", ] [[package]] @@ -3727,6 +3924,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "spin" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "023a211cb3138dbc438680b32560ad89f699977624c9f8dbb95a47d5b4c07dd3" + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -3794,6 +3997,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -4283,9 +4497,21 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" dependencies = [ "pin-project-lite", + "tracing-attributes", "tracing-core", ] +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "tracing-core" version = "0.1.36" @@ -4447,7 +4673,7 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5" dependencies = [ - "itertools", + "itertools 0.14.0", "unicode-segmentation", "unicode-width", ] @@ -4963,6 +5189,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" diff --git a/Cargo.toml b/Cargo.toml index ec3e2606..a7eef89f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -37,7 +37,7 @@ 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.16.1", path = "crates/path-cli" } +path-cli = { version = "0.17.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/README.md b/README.md index d3a82eba..69c463ad 100644 --- a/README.md +++ b/README.md @@ -125,9 +125,25 @@ path p export pathbase --input claude- # (full URL or bare `//` triple) path p import pathbase https://pathbase.dev/alex/pathstash/path-pr-42 +# Send shares somewhere else instead. Designate it once, and bare +# `path share` goes there from then on. +# Your ~/.aws profiles are picked up automatically, SSO included — +# `path auth s3 login` is only for endpoints AWS tooling doesn't know +# about (MinIO, R2, Ceph). +path target ~/Dropbox/toolpath-traces # a folder — no credentials needed +path target s3://my-bucket/traces # or a bucket; checked before it's stored +path share # → wherever you pointed it +path target # what's in effect, and why + +# Override for one call, without changing the default +path share --to /tmp/scratch +path share --to pathbase + # Resume a Toolpath document into your coding agent of choice (interactive # harness picker; project the session and exec the harness's resume command) path resume https://pathbase.dev/alex/pathstash/path-pr-42 +path resume ~/Dropbox/toolpath-traces # lists what you've shared, pick one +path resume s3://my-bucket/traces/2026-08-07-fix-the-parser-claude-abc.json path resume claude- --harness claude -C /path/to/project # Query the whole local cache with a jaq (jq) filter over wrapped steps @@ -160,14 +176,26 @@ path codex --session ID opencode --session ID pi --project PATH --session ID [--base DIR] - share # one-shot interactive picker + Pathbase upload + share # one-shot interactive picker + upload to the share target + [--to pathbase|s3://BUCKET/PREFIX|FOLDER] + [--harness NAME] [--session ID] [--project PATH] [--no-cache] + [--url URL] [--anon] [--repo OWNER/NAME] [--name TEXT] [--public] resume # project a doc into a coding agent and exec --resume + # INPUT: pathbase URL | s3://…/doc.json | a destination to + # browse | owner/repo/slug | file | cache id query # jaq (jq) filter over cached steps FILTER [--source NAME] [--id CACHE-ID] [--input FILE] [--project PATH] [--kind SELECTOR] [-c] [-r] kind # list bundled kinds, or print a kind's schema [KIND[/VERSION]] auth login | status | whoami | logout [--url URL] + s3 login [--region R] [--endpoint URL] [--access-key-id ID] + [--secret-access-key KEY] [--session-token TOK] + [--profile NAME] [--virtual-hosted-style] + s3 status | s3 logout + target # where `path share` uploads; no argument prints it. + # Setting one writes a probe object to prove it works. + [pathbase | s3://BUCKET/PREFIX | FOLDER] [--clear] [--no-verify] p # plumbing: lower-level building blocks query ancestors --input FILE --step-id ID @@ -188,10 +216,12 @@ path opencode [--session ID] [--all] [--project ID] [--no-snapshot-diffs] pi [--project PATH] [--session ID] [--all] [--base DIR] pathbase TRACE-ID-OR-URL [--url URL] + object URL # s3:// or file://; alias: s3 # global: [--force] [--no-cache] export claude --input REF [--project DIR | --output FILE] pathbase --input REF [--url URL] + object --input REF [--to DEST] # alias: s3 cache ls | rm CACHE-ID render diff --git a/crates/path-cli/Cargo.toml b/crates/path-cli/Cargo.toml index 1ce394b6..d07f0afc 100644 --- a/crates/path-cli/Cargo.toml +++ b/crates/path-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "path-cli" -version = "0.16.1" +version = "0.17.0" edition.workspace = true license.workspace = true repository = "https://github.com/empathic/toolpath" @@ -42,6 +42,13 @@ jaq-std = "3.0.1" jaq-json = "2.0.1" [target.'cfg(not(target_os = "emscripten"))'.dependencies] +# Share/resume over S3. `aws` covers every S3-compatible endpoint; +# the default `fs` feature gives `file://` for local destinations and +# for the round-trip tests. +object_store = { version = "0.14.1", features = ["aws"] } +url = "2" +# No-echo prompt for the S3 secret access key in `path auth s3 login`. +rpassword = "7" toolpath-claude = { workspace = true, features = ["watcher"] } toolpath-gemini = { workspace = true, features = ["watcher"] } toolpath-codex = { workspace = true } diff --git a/crates/path-cli/src/aws_creds.rs b/crates/path-cli/src/aws_creds.rs new file mode 100644 index 00000000..7badc6e0 --- /dev/null +++ b/crates/path-cli/src/aws_creds.rs @@ -0,0 +1,607 @@ +//! Where S3 credentials actually come from. +//! +//! `object_store` resolves static keys, EKS/IRSA web identity, ECS task +//! roles, and EC2 instance metadata — the *server* cases. It reads no +//! `~/.aws/credentials`, no `~/.aws/config`, no `AWS_PROFILE`, and no +//! SSO, because it deliberately avoids depending on the AWS SDK. That +//! leaves out how nearly every developer actually has S3 access on a +//! laptop. +//! +//! This module fills that in without taking on the SDK: +//! +//! - **Static-key profiles** are read directly. `~/.aws/credentials` is +//! a trivial ini file and the keys are right there. +//! - **Everything else** — SSO, `role_arn` chains, `credential_process` +//! — is delegated to `aws configure export-credentials`, which runs +//! the AWS CLI's own resolver and hands back concrete keys. Anyone +//! using SSO already has the CLI, since `aws sso login` is how they +//! authenticate; and delegating means refresh, cache layout, and +//! every future profile type stay the CLI's problem, not ours. +//! +//! Depending on `aws-config` instead would be 31 crates, and its whole +//! family currently requires rustc 1.94.1 while this repo pins 1.94.0 +//! — an MSRV treadmill on a toolchain we pin exactly. + +#![cfg(not(target_os = "emscripten"))] + +use anyhow::{Context, Result, bail}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +/// Concrete keys, plus where they came from. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Credentials { + pub access_key_id: String, + pub secret_access_key: String, + pub session_token: Option, +} + +/// Which resolution step supplied the credentials. +/// +/// Carried all the way to `path auth s3 status`, because the first +/// question when a share fails is always *which* credential was used — +/// and "the one you configured" and "whatever the AWS CLI resolved" are +/// very different debugging stories. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Source { + /// Stored by `path auth s3 login`. + Stored, + /// `AWS_ACCESS_KEY_ID` and friends. + Environment, + /// Static keys in `~/.aws/credentials`. + Profile { name: String, file: PathBuf }, + /// Resolved by the AWS CLI (SSO, assume-role, credential_process). + AwsCli { name: String }, + /// Nothing local; `object_store` will try instance metadata, ECS, + /// and web identity on its own. + InstanceChain, +} + +impl std::fmt::Display for Source { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Source::Stored => f.write_str("stored by `path auth s3 login`"), + Source::Environment => f.write_str("AWS_ACCESS_KEY_ID (environment)"), + Source::Profile { name, file } => { + write!(f, "profile `{name}` in {}", file.display()) + } + Source::AwsCli { name } => { + write!(f, "profile `{name}` via the AWS CLI") + } + Source::InstanceChain => { + f.write_str("none found locally — will try the EC2/ECS/EKS credential chain") + } + } + } +} + +/// The outcome of resolution: keys when we found them, always a source. +#[derive(Debug, Clone)] +pub(crate) struct Resolved { + pub credentials: Option, + pub source: Source, + /// Region the profile declares, if any — `~/.aws/config` is the + /// natural place for it and re-typing it would be silly. + pub region: Option, +} + +/// Everything resolution reads, injected so tests need no `$HOME` +/// games and no real AWS CLI. +pub(crate) struct Env<'a> { + pub home: Option, + pub var: &'a dyn Fn(&str) -> Option, + /// Runs `aws configure export-credentials --profile `, + /// returning its stdout. + pub aws_cli: &'a dyn Fn(&str) -> Result, +} + +impl Env<'_> { + fn credentials_file(&self) -> Option { + let explicit = self + .var("AWS_SHARED_CREDENTIALS_FILE") + .filter(|v| !v.trim().is_empty()); + match explicit { + Some(p) => Some(PathBuf::from(p)), + None => self.home.as_ref().map(|h| h.join(".aws/credentials")), + } + } + + fn config_file(&self) -> Option { + let explicit = self.var("AWS_CONFIG_FILE").filter(|v| !v.trim().is_empty()); + match explicit { + Some(p) => Some(PathBuf::from(p)), + None => self.home.as_ref().map(|h| h.join(".aws/config")), + } + } + + fn var(&self, key: &str) -> Option { + (self.var)(key) + } +} + +/// Resolve S3 credentials, following AWS's own precedence with our +/// stored settings layered on top. +/// +/// 1. `path auth s3 login` — an explicit local choice beats ambient +/// state, and it's the only way to configure a non-AWS endpoint. +/// 2. `--profile` / `AWS_PROFILE` — naming a profile is also explicit. +/// 3. `AWS_ACCESS_KEY_ID` — what CI sets. +/// 4. The `[default]` profile. +/// 5. Nothing: leave it to `object_store`'s instance chain. +pub(crate) fn resolve( + stored: Option, + profile_flag: Option<&str>, + env: &Env<'_>, +) -> Result { + if let Some(credentials) = stored { + return Ok(Resolved { + credentials: Some(credentials), + source: Source::Stored, + region: None, + }); + } + + let named = profile_flag + .map(str::to_string) + .or_else(|| env.var("AWS_PROFILE")) + .filter(|p| !p.trim().is_empty()); + + if let Some(name) = &named { + return from_profile(name, env).with_context(|| format!("profile `{name}`")); + } + + if let (Some(key), Some(secret)) = ( + env.var("AWS_ACCESS_KEY_ID") + .filter(|v| !v.trim().is_empty()), + env.var("AWS_SECRET_ACCESS_KEY") + .filter(|v| !v.trim().is_empty()), + ) { + return Ok(Resolved { + credentials: Some(Credentials { + access_key_id: key, + secret_access_key: secret, + session_token: env.var("AWS_SESSION_TOKEN").filter(|v| !v.is_empty()), + }), + source: Source::Environment, + region: env + .var("AWS_REGION") + .or_else(|| env.var("AWS_DEFAULT_REGION")), + }); + } + + // A `[default]` profile is only used when it exists; its absence is + // not an error, it just means we fall through to the chain. + if profile_exists("default", env) { + return from_profile("default", env).context("profile `default`"); + } + + Ok(Resolved { + credentials: None, + source: Source::InstanceChain, + region: None, + }) +} + +fn profile_exists(name: &str, env: &Env<'_>) -> bool { + let in_credentials = env + .credentials_file() + .and_then(|p| read_ini(&p).ok()) + .is_some_and(|ini| ini.contains_key(name)); + let in_config = env + .config_file() + .and_then(|p| read_ini(&p).ok()) + .is_some_and(|ini| ini.contains_key(&config_section(name)) || ini.contains_key(name)); + in_credentials || in_config +} + +/// Read one profile: static keys if it has them, otherwise the AWS CLI. +fn from_profile(name: &str, env: &Env<'_>) -> Result { + let credentials_path = env.credentials_file(); + let region = profile_region(name, env); + + if let Some(path) = &credentials_path + && let Ok(ini) = read_ini(path) + && let Some(section) = ini.get(name) + && let (Some(key), Some(secret)) = ( + section.get("aws_access_key_id"), + section.get("aws_secret_access_key"), + ) + { + return Ok(Resolved { + credentials: Some(Credentials { + access_key_id: key.clone(), + secret_access_key: secret.clone(), + session_token: section.get("aws_session_token").cloned(), + }), + source: Source::Profile { + name: name.to_string(), + file: path.clone(), + }, + region, + }); + } + + if !profile_exists(name, env) { + bail!( + "no such profile. Check `aws configure list-profiles`, or run \ + `path auth s3 login` to store keys directly." + ); + } + + // The profile exists but carries no static keys: SSO, an assume-role + // chain, or credential_process. The AWS CLI already knows how to + // resolve all of those, including refresh. + let raw = (env.aws_cli)(name)?; + let creds = parse_export_credentials(&raw)?; + Ok(Resolved { + credentials: Some(creds), + source: Source::AwsCli { + name: name.to_string(), + }, + region, + }) +} + +fn profile_region(name: &str, env: &Env<'_>) -> Option { + let ini = read_ini(&env.config_file()?).ok()?; + // `~/.aws/config` spells non-default profiles `[profile name]`. + let section = ini.get(&config_section(name)).or_else(|| ini.get(name))?; + section.get("region").cloned() +} + +fn config_section(name: &str) -> String { + if name == "default" { + name.to_string() + } else { + format!("profile {name}") + } +} + +/// `aws configure export-credentials --format process` output. +fn parse_export_credentials(raw: &str) -> Result { + let v: serde_json::Value = + serde_json::from_str(raw.trim()).context("the AWS CLI returned output that isn't JSON")?; + let field = |k: &str| v.get(k).and_then(|x| x.as_str()).map(str::to_string); + let (Some(access_key_id), Some(secret_access_key)) = + (field("AccessKeyId"), field("SecretAccessKey")) + else { + bail!("the AWS CLI returned no credentials"); + }; + Ok(Credentials { + access_key_id, + secret_access_key, + session_token: field("SessionToken"), + }) +} + +/// Run the real AWS CLI. Kept behind [`Env::aws_cli`] so tests don't. +pub(crate) fn run_aws_cli(profile: &str) -> Result { + let out = std::process::Command::new("aws") + .args(["configure", "export-credentials", "--profile", profile]) + .args(["--format", "process"]) + .output() + .map_err(|e| match e.kind() { + std::io::ErrorKind::NotFound => anyhow::anyhow!( + "this profile needs the AWS CLI to resolve (SSO, assume-role, or \ + credential_process), but `aws` isn't on PATH. Install it, or run \ + `path auth s3 login` to store keys directly." + ), + _ => anyhow::anyhow!("running `aws configure export-credentials`: {e}"), + })?; + if !out.status.success() { + let stderr = String::from_utf8_lossy(&out.stderr); + let hint = if stderr.contains("sso") || stderr.contains("SSO") { + "\nIf this is an SSO profile, run `aws sso login` first." + } else { + "" + }; + bail!( + "the AWS CLI could not resolve this profile: {}{hint}", + stderr.trim() + ); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) +} + +// ── Minimal ini ───────────────────────────────────────────────────────── + +/// Parse the subset of ini that AWS config files actually use: +/// `[section]` headers, `key = value` pairs, `#`/`;` comments. +/// +/// Nested sub-sections (the `[services]` style) are ignored rather than +/// mis-parsed — nothing we read lives in one. +fn read_ini(path: &Path) -> Result>> { + let text = std::fs::read_to_string(path)?; + Ok(parse_ini(&text)) +} + +fn parse_ini(text: &str) -> HashMap> { + let mut out: HashMap> = HashMap::new(); + let mut current: Option = None; + for line in text.lines() { + // Indented lines continue a nested sub-section; skip them. + if line.starts_with(char::is_whitespace) { + continue; + } + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || line.starts_with(';') { + continue; + } + if let Some(name) = line.strip_prefix('[').and_then(|l| l.strip_suffix(']')) { + let name = name.trim().to_string(); + out.entry(name.clone()).or_default(); + current = Some(name); + continue; + } + if let (Some(section), Some((key, value))) = (current.as_ref(), line.split_once('=')) { + out.entry(section.clone()) + .or_default() + .insert(key.trim().to_ascii_lowercase(), value.trim().to_string()); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// An `Env` over an in-memory fake `~/.aws` and a stub AWS CLI, so + /// nothing here touches the developer's real credentials. + struct Fake { + dir: tempfile::TempDir, + vars: HashMap, + cli_result: std::cell::RefCell>, + cli_calls: std::cell::RefCell>, + } + + impl Fake { + fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir_all(dir.path().join(".aws")).unwrap(); + Fake { + dir, + vars: HashMap::new(), + cli_result: std::cell::RefCell::new(Err("aws CLI not stubbed".into())), + cli_calls: std::cell::RefCell::new(Vec::new()), + } + } + fn credentials(self, body: &str) -> Self { + std::fs::write(self.dir.path().join(".aws/credentials"), body).unwrap(); + self + } + fn config(self, body: &str) -> Self { + std::fs::write(self.dir.path().join(".aws/config"), body).unwrap(); + self + } + fn var(mut self, k: &str, v: &str) -> Self { + self.vars.insert(k.to_string(), v.to_string()); + self + } + fn cli(self, out: &str) -> Self { + *self.cli_result.borrow_mut() = Ok(out.to_string()); + self + } + fn resolve(&self, stored: Option, profile: Option<&str>) -> Result { + let var = |k: &str| self.vars.get(k).cloned(); + let cli = |name: &str| { + self.cli_calls.borrow_mut().push(name.to_string()); + self.cli_result + .borrow() + .clone() + .map_err(|e| anyhow::anyhow!(e)) + }; + resolve( + stored, + profile, + &Env { + home: Some(self.dir.path().to_path_buf()), + var: &var, + aws_cli: &cli, + }, + ) + } + } + + const STATIC_PROFILE: &str = "\ +[default] +aws_access_key_id = AKIADEFAULT +aws_secret_access_key = defaultsecret + +[work] +aws_access_key_id = AKIAWORK +aws_secret_access_key = worksecret +aws_session_token = worktoken +"; + + #[test] + fn a_default_profile_is_used_with_no_configuration_at_all() { + // The whole point: someone who has run `aws configure` gets S3 + // access without telling us anything. + let f = Fake::new().credentials(STATIC_PROFILE); + let r = f.resolve(None, None).unwrap(); + let c = r.credentials.unwrap(); + assert_eq!(c.access_key_id, "AKIADEFAULT"); + assert!(matches!(r.source, Source::Profile { .. })); + } + + #[test] + fn an_explicit_profile_beats_the_default() { + let f = Fake::new().credentials(STATIC_PROFILE); + let r = f.resolve(None, Some("work")).unwrap(); + let c = r.credentials.unwrap(); + assert_eq!(c.access_key_id, "AKIAWORK"); + assert_eq!(c.session_token.as_deref(), Some("worktoken")); + } + + #[test] + fn aws_profile_env_selects_the_profile() { + let f = Fake::new() + .credentials(STATIC_PROFILE) + .var("AWS_PROFILE", "work"); + let r = f.resolve(None, None).unwrap(); + assert_eq!(r.credentials.unwrap().access_key_id, "AKIAWORK"); + } + + #[test] + fn the_profile_flag_beats_aws_profile() { + let f = Fake::new() + .credentials(STATIC_PROFILE) + .var("AWS_PROFILE", "work"); + let r = f.resolve(None, Some("default")).unwrap(); + assert_eq!(r.credentials.unwrap().access_key_id, "AKIADEFAULT"); + } + + #[test] + fn stored_settings_beat_everything_ambient() { + // Running `path auth s3 login` is an explicit local choice, and + // it's the only way to reach a non-AWS endpoint. + let f = Fake::new() + .credentials(STATIC_PROFILE) + .var("AWS_ACCESS_KEY_ID", "AKIAENV") + .var("AWS_SECRET_ACCESS_KEY", "envsecret"); + let stored = Credentials { + access_key_id: "AKIASTORED".into(), + secret_access_key: "storedsecret".into(), + session_token: None, + }; + let r = f.resolve(Some(stored), None).unwrap(); + assert_eq!(r.credentials.unwrap().access_key_id, "AKIASTORED"); + assert_eq!(r.source, Source::Stored); + } + + #[test] + fn env_keys_beat_the_default_profile() { + // AWS's own precedence, and what CI sets. + let f = Fake::new() + .credentials(STATIC_PROFILE) + .var("AWS_ACCESS_KEY_ID", "AKIAENV") + .var("AWS_SECRET_ACCESS_KEY", "envsecret"); + let r = f.resolve(None, None).unwrap(); + assert_eq!(r.credentials.unwrap().access_key_id, "AKIAENV"); + assert_eq!(r.source, Source::Environment); + } + + #[test] + fn nothing_configured_defers_to_the_instance_chain() { + // On a server this is the correct answer, not a failure. + let f = Fake::new(); + let r = f.resolve(None, None).unwrap(); + assert!(r.credentials.is_none()); + assert_eq!(r.source, Source::InstanceChain); + } + + #[test] + fn an_sso_profile_is_resolved_through_the_aws_cli() { + // No static keys in the file — exactly what `aws sso login` + // leaves behind. The CLI knows how to turn it into keys. + let f = Fake::new() + .config("[profile sso-work]\nsso_session = corp\nsso_account_id = 1234\n") + .cli(r#"{"Version":1,"AccessKeyId":"ASIASSO","SecretAccessKey":"ssosecret","SessionToken":"ssotoken"}"#); + let r = f.resolve(None, Some("sso-work")).unwrap(); + let c = r.credentials.unwrap(); + assert_eq!(c.access_key_id, "ASIASSO"); + assert_eq!(c.session_token.as_deref(), Some("ssotoken")); + assert_eq!( + r.source, + Source::AwsCli { + name: "sso-work".into() + } + ); + assert_eq!(*f.cli_calls.borrow(), vec!["sso-work".to_string()]); + } + + #[test] + fn a_static_profile_never_shells_out() { + let f = Fake::new().credentials(STATIC_PROFILE); + f.resolve(None, Some("work")).unwrap(); + assert!( + f.cli_calls.borrow().is_empty(), + "static keys are right there; spawning the AWS CLI would be silly" + ); + } + + #[test] + fn an_unknown_profile_says_so_rather_than_shelling_out() { + let f = Fake::new().credentials(STATIC_PROFILE); + let err = f.resolve(None, Some("nope")).unwrap_err().to_string(); + assert!(err.contains("nope"), "{err}"); + assert!(f.cli_calls.borrow().is_empty()); + } + + #[test] + fn region_comes_from_the_profile_when_we_have_none() { + // `~/.aws/config` spells non-default profiles `[profile name]`. + let f = Fake::new() + .credentials(STATIC_PROFILE) + .config("[default]\nregion = us-east-2\n\n[profile work]\nregion = eu-west-1\n"); + assert_eq!( + f.resolve(None, Some("work")).unwrap().region.as_deref(), + Some("eu-west-1") + ); + assert_eq!( + f.resolve(None, None).unwrap().region.as_deref(), + Some("us-east-2") + ); + } + + #[test] + fn credentials_file_location_honors_the_aws_env_overrides() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("elsewhere"); + std::fs::write(&path, STATIC_PROFILE).unwrap(); + let f = Fake::new().var("AWS_SHARED_CREDENTIALS_FILE", path.to_str().unwrap()); + assert_eq!( + f.resolve(None, None) + .unwrap() + .credentials + .unwrap() + .access_key_id, + "AKIADEFAULT" + ); + } + + // ── ini parsing ────────────────────────────────────────────────── + + #[test] + fn ini_ignores_comments_and_nested_subsections() { + let ini = parse_ini( + "\ +# a comment +; another +[default] +aws_access_key_id = AKIA ; trailing content is part of the value +region=us-east-1 + +[services x] + s3 = + endpoint_url = http://nested +", + ); + assert_eq!(ini["default"]["region"], "us-east-1"); + // Indented sub-section bodies must not leak into the section. + assert!(!ini["services x"].contains_key("endpoint_url")); + } + + #[test] + fn ini_keys_are_case_insensitive() { + let ini = parse_ini("[default]\nAWS_ACCESS_KEY_ID = AKIA\n"); + assert_eq!(ini["default"]["aws_access_key_id"], "AKIA"); + } + + #[test] + fn export_credentials_output_without_keys_is_an_error() { + let err = parse_export_credentials(r#"{"Version":1}"#) + .unwrap_err() + .to_string(); + assert!(err.contains("no credentials"), "{err}"); + } + + #[test] + fn export_credentials_non_json_is_an_error() { + let err = parse_export_credentials("Unable to locate credentials") + .unwrap_err() + .to_string(); + assert!(err.contains("isn't JSON"), "{err}"); + } +} diff --git a/crates/path-cli/src/cmd_auth.rs b/crates/path-cli/src/cmd_auth.rs index 621dbc4a..57a9c20f 100644 --- a/crates/path-cli/src/cmd_auth.rs +++ b/crates/path-cli/src/cmd_auth.rs @@ -1,11 +1,14 @@ use anyhow::{Result, anyhow}; -use clap::Subcommand; +use clap::{Args, Subcommand}; +use std::io::IsTerminal; use std::path::Path; use crate::cmd_pathbase::{ StoredSession, api_logout, api_me, api_redeem, clear_session, credentials_path, load_session, prompt_line, resolve_url, store_session, }; +use crate::store::{self, S3Settings}; +use crate::target; #[derive(Subcommand, Debug)] pub enum AuthOp { @@ -25,15 +28,86 @@ pub enum AuthOp { Status, /// Verify the stored session against the server and print the current user Whoami, + /// Store S3 credentials once, so `s3://` share and resume targets + /// need none on the command line. A folder target needs no + /// credentials and so never needs this. + S3 { + #[command(subcommand)] + op: S3Op, + }, +} + +#[derive(Subcommand, Debug)] +pub enum S3Op { + /// Store S3 credentials and connection settings. + /// + /// Only the fields you pass are updated; the rest keep their stored + /// values, so `path auth s3 login --region eu-west-1` is a valid + /// tweak. Run interactively with no flags and it prompts, without + /// echoing the secret. + /// + /// This does not set *where* shares go — that's `path target` + /// — so one stored credential serves any number of buckets. + #[command(alias = "set")] + Login { + #[command(flatten)] + args: S3LoginArgs, + }, + /// Show the S3 settings in effect, with secrets redacted and + /// environment-supplied values marked + Status, + /// Forget the stored S3 settings + #[command(alias = "clear")] + Logout, +} + +#[derive(Args, Debug, Default)] +pub struct S3LoginArgs { + /// AWS region (default: us-east-1) + #[arg(long)] + pub region: Option, + + /// Endpoint URL for an S3-compatible service (R2, MinIO, Ceph). + /// Omit for real AWS S3. + #[arg(long)] + pub endpoint: Option, + + #[arg(long)] + pub access_key_id: Option, + + /// Secret access key. Prefer omitting this so it's prompted for + /// rather than landing in your shell history. + #[arg(long)] + pub secret_access_key: Option, + + /// Session token for temporary (STS / assumed-role) credentials + #[arg(long)] + pub session_token: Option, + + /// AWS profile to resolve credentials from, instead of storing keys. + /// Works with SSO and assume-role profiles — those are resolved + /// through the AWS CLI, so nothing expires in our config. + #[arg(long)] + pub profile: Option, + + /// Address the bucket as `bucket.host/key` instead of `host/bucket/key` + #[arg(long)] + pub virtual_hosted_style: bool, } pub fn run(op: AuthOp) -> Result<()> { - let path = credentials_path()?; match op { - AuthOp::Login { url, code } => login(&path, url, code), - AuthOp::Logout => logout(&path), - AuthOp::Status => status(&path), - AuthOp::Whoami => whoami(&path), + AuthOp::S3 { op } => run_s3(op), + other => { + let path = credentials_path()?; + match other { + AuthOp::Login { url, code } => login(&path, url, code), + AuthOp::Logout => logout(&path), + AuthOp::Status => status(&path), + AuthOp::Whoami => whoami(&path), + AuthOp::S3 { .. } => unreachable!("handled above"), + } + } } } @@ -113,6 +187,217 @@ fn status(path: &Path) -> Result<()> { } } +// ── S3 ────────────────────────────────────────────────────────────────── + +fn run_s3(op: S3Op) -> Result<()> { + let path = store::config_path()?; + match op { + S3Op::Login { args } => s3_login(&path, args), + S3Op::Status => s3_status(&path), + S3Op::Logout => s3_logout(&path), + } +} + +/// Merge `args` into whatever is already stored, prompting for the +/// essentials when nothing was passed and we have a terminal. +/// +/// Merge rather than replace: partial updates are the common case +/// (rotating a key, switching endpoint), and a replace would silently +/// drop the fields the user didn't repeat. +fn s3_login(path: &Path, args: S3LoginArgs) -> Result<()> { + let mut cfg = store::load_stored(path)?.unwrap_or_default(); + let had_settings = cfg != S3Settings::default(); + + let set = |slot: &mut Option, value: Option| { + if let Some(v) = value + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()) + { + *slot = Some(v); + } + }; + set(&mut cfg.region, args.region); + set(&mut cfg.endpoint, args.endpoint); + set(&mut cfg.access_key_id, args.access_key_id); + set(&mut cfg.secret_access_key, args.secret_access_key); + set(&mut cfg.session_token, args.session_token); + set(&mut cfg.profile, args.profile); + if args.virtual_hosted_style { + cfg.virtual_hosted_style = Some(true); + } + + if std::io::stdin().is_terminal() && !had_settings { + prompt_missing(&mut cfg)?; + } + + if cfg == S3Settings::default() { + anyhow::bail!( + "Nothing to store. Pass at least one setting (e.g. \ + `path auth s3 login --access-key-id AKIA… --secret-access-key …`), \ + or run this from a terminal to be prompted." + ); + } + + store::store(path, &cfg)?; + println!("S3 settings saved to {}", path.display()); + // Everything printed here was just written, so nothing is `(env)`. + print_settings(&cfg, &cfg); + suggest_default_target()?; + Ok(()) +} + +/// First-time interactive setup. Skipped when settings already exist, +/// so a targeted `--region` update doesn't re-interrogate the user +/// about credentials they already stored. +fn prompt_missing(cfg: &mut S3Settings) -> Result<()> { + println!("Store S3 connection settings for `s3://` share and resume targets."); + println!(); + println!("If you already use the AWS CLI, you probably need none of this — your"); + println!("`~/.aws` profiles are picked up automatically, including SSO. This is"); + println!("for endpoints the AWS tooling doesn't know about (MinIO, R2, Ceph)."); + println!("Leave any field blank to skip it."); + println!(); + + if cfg.region.is_none() { + let v = prompt_line(&format!("Region [{}]: ", store::DEFAULT_REGION))?; + cfg.region = Some(v).filter(|v| !v.is_empty()); + } + if cfg.endpoint.is_none() { + let v = prompt_line("Endpoint URL (blank for AWS): ")?; + cfg.endpoint = Some(v).filter(|v| !v.is_empty()); + } + if cfg.access_key_id.is_none() { + let v = prompt_line("Access key id (blank to use the AWS environment): ")?; + cfg.access_key_id = Some(v).filter(|v| !v.is_empty()); + } + if cfg.access_key_id.is_some() && cfg.secret_access_key.is_none() { + let v = rpassword::prompt_password("Secret access key: ")?; + cfg.secret_access_key = Some(v.trim().to_string()).filter(|v| !v.is_empty()); + } + Ok(()) +} + +/// Credentials alone don't make `path share` go to S3 — the target +/// does. Close that gap in the same breath rather than letting the user +/// discover it on their next share. +fn suggest_default_target() -> Result<()> { + if target::default_target()?.0.is_some() { + return Ok(()); + } + println!(); + println!("`path share` still uploads to Pathbase. To make S3 the default:"); + println!(" path target s3://my-bucket/traces"); + Ok(()) +} + +fn s3_status(path: &Path) -> Result<()> { + let stored = store::load_stored(path)?; + let effective = store::effective_settings()?; + + match &stored { + Some(_) => println!("S3 settings in {}", path.display()), + None => println!("No stored S3 settings ({} does not exist).", path.display()), + } + if effective == S3Settings::default() { + println!("Run `path auth s3 login` to store some."); + } else { + print_settings(&effective, &stored.unwrap_or_default()); + } + print_credential_source(&effective); + println!("Share target: {}", target::describe_effective()?); + Ok(()) +} + +fn s3_logout(path: &Path) -> Result<()> { + if store::load_stored(path)?.is_none() { + println!("No stored S3 settings."); + return Ok(()); + } + store::clear(path)?; + println!("S3 settings cleared."); + Ok(()) +} + +/// Print `effective`, tagging any field that `stored` didn't supply as +/// `(env)` — otherwise "where did this endpoint come from?" is a guess. +fn print_settings(effective: &S3Settings, stored: &S3Settings) { + let line = |label: &str, value: Option<&str>, from_store: bool| { + if let Some(v) = value { + let origin = if from_store { "" } else { " (env)" }; + // Width matches the longest label so the values line up. + println!(" {:<19}{v}{origin}", format!("{label}:")); + } + }; + line( + "region", + effective.region.as_deref(), + stored.region.is_some(), + ); + line( + "endpoint", + effective.endpoint.as_deref(), + stored.endpoint.is_some(), + ); + line( + "access key id", + effective.access_key_id.as_deref(), + stored.access_key_id.is_some(), + ); + line( + "secret access key", + effective + .secret_access_key + .as_deref() + .map(redact) + .as_deref(), + stored.secret_access_key.is_some(), + ); + line( + "session token", + effective.session_token.as_deref().map(redact).as_deref(), + stored.session_token.is_some(), + ); + line( + "profile", + effective.profile.as_deref(), + stored.profile.is_some(), + ); +} + +/// Say which credentials a share would actually use. +/// +/// The first question when an upload fails is *which* credential was +/// tried — a stored key, an AWS profile, or nothing at all are three +/// completely different fixes, and only this line distinguishes them. +fn print_credential_source(effective: &S3Settings) { + match effective.resolve_real() { + Ok(r) => { + println!(" credentials: {}", r.source); + if let Some(region) = &r.region + && effective.region.is_none() + { + println!(" region: {region} (from the profile)"); + } + } + // The reason *is* the answer here — "no such profile" tells the + // user exactly what to fix. + Err(e) => println!(" credentials: unresolved — {e:#}"), + } +} + +/// Show enough of a secret to recognize which one it is, and no more. +fn redact(secret: &str) -> String { + let tail: String = secret + .chars() + .rev() + .take(4) + .collect::>() + .into_iter() + .rev() + .collect(); + format!("****{tail}") +} + fn whoami(path: &Path) -> Result<()> { let stored = load_session(path)?.ok_or_else(|| anyhow!("Not logged in. Run `path auth login`."))?; diff --git a/crates/path-cli/src/cmd_export.rs b/crates/path-cli/src/cmd_export.rs index 42cd7e00..1c452242 100644 --- a/crates/path-cli/src/cmd_export.rs +++ b/crates/path-cli/src/cmd_export.rs @@ -193,6 +193,25 @@ pub enum ExportTarget { #[arg(long)] public: bool, }, + /// Upload a toolpath document to object storage: an S3 bucket, an + /// S3-compatible endpoint, or a plain folder. + /// + /// S3 credentials come from `path auth s3 login`, with the AWS + /// environment as the fallback; a folder needs none. The object + /// lands at `/.json`, and the printed location is + /// what `path resume` takes. + #[command(alias = "s3")] + Object { + /// Input: cache id (e.g. `claude-abc`) or path to a toolpath JSON file + #[arg(short, long)] + input: String, + + /// Destination: `s3://bucket/prefix`, or a folder (`~/traces`, + /// `file:///srv/traces`). Defaults to the target from + /// `path target`. + #[arg(long, value_name = "DESTINATION")] + to: Option, + }, } /// `owner/name` pair for `--repo`. @@ -267,6 +286,7 @@ pub fn run(target: ExportTarget) -> Result<()> { name, public, }), + ExportTarget::Object { input, to } => run_object(input, to), } } @@ -1854,6 +1874,50 @@ fn write_cursor_to_stdout(session: &toolpath_cursor::CursorSession) -> Result<() Ok(()) } +// ── Object storage ──────────────────────────────────────────────────── + +fn run_object(input: String, to: Option) -> Result<()> { + #[cfg(target_os = "emscripten")] + { + let _ = (input, to); + anyhow::bail!("'path p export object' requires a native environment with network access"); + } + + #[cfg(not(target_os = "emscripten"))] + { + let file = cache_ref(&input)?; + let body = std::fs::read_to_string(&file) + .with_context(|| format!("Failed to read {}", file.display()))?; + + // Share the porcelain's target resolution so `p export object` + // with no `--to` writes where `path share` would. + let dest = match crate::target::resolve(to.as_deref())?.0 { + crate::target::Target::Object(d) => d, + crate::target::Target::Pathbase => anyhow::bail!( + "the configured share target is Pathbase, not object storage. \ + Pass `--to s3://bucket/prefix` (or a folder), or use \ + `path p export pathbase`." + ), + }; + let settings = crate::store::effective_settings()?; + + // The object is named for the cache id, not the input string — + // `--input ./some/file.json` and `--input claude-abc` naming the + // same document should land on the same key. + let cache_id = file + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| input.clone()); + + let uri = dest.uri_for(&crate::store::name_for_body(&body, &cache_id)); + uri.put(&settings, body.as_bytes())?; + println!("{uri}"); + eprintln!("Uploaded {} bytes → {uri}", body.len()); + eprintln!("Resume it with: path resume {uri}"); + Ok(()) + } +} + // ── Pathbase ────────────────────────────────────────────────────────── fn run_pathbase(args: PathbaseExportArgs) -> Result<()> { diff --git a/crates/path-cli/src/cmd_import.rs b/crates/path-cli/src/cmd_import.rs index 7d210beb..0534cbc0 100644 --- a/crates/path-cli/src/cmd_import.rs +++ b/crates/path-cli/src/cmd_import.rs @@ -183,6 +183,17 @@ pub enum ImportSource { #[arg(long)] url: Option, }, + /// Import from object storage — an S3 bucket, an S3-compatible + /// endpoint, or a folder (the inverse of `path share --to`). + /// S3 credentials come from `path auth s3 login` or the AWS + /// environment; a folder needs none. + #[command(alias = "s3")] + Object { + /// Object URL: `s3://bucket/key.json` (also `s3a://`, and + /// `file:///dir/key.json` for a local folder) + #[arg(index = 1)] + target: String, + }, } #[derive(clap::Args, Debug)] @@ -301,6 +312,7 @@ fn derive(source: ImportSource) -> Result> { base, } => derive_pi(project, session, all, base), ImportSource::Pathbase { target, url } => derive_pathbase(target, url), + ImportSource::Object { target } => derive_object(target), } } @@ -1531,6 +1543,19 @@ fn derive_pathbase(target: String, url_flag: Option) -> Result Result> { + #[cfg(target_os = "emscripten")] + { + let _ = target; + anyhow::bail!("'path p import object' requires a native environment with network access"); + } + + #[cfg(not(target_os = "emscripten"))] + { + Ok(vec![crate::derive::object_fetch_to_doc(&target)?]) + } +} + #[cfg(all(test, not(target_os = "emscripten")))] mod tests { use super::*; diff --git a/crates/path-cli/src/cmd_pathbase.rs b/crates/path-cli/src/cmd_pathbase.rs index 020a0078..63cd6594 100644 --- a/crates/path-cli/src/cmd_pathbase.rs +++ b/crates/path-cli/src/cmd_pathbase.rs @@ -333,7 +333,10 @@ fn short_body(body: &str) -> String { // hand-rolled only because the redeem endpoint isn't in the OpenAPI // spec, not because of any HTTP-stack difference. -fn block_on(f: F) -> F::Output { +/// Shared with the `s3` module, which drives the equally-async +/// `object_store` client: one runtime for every async client the CLI +/// tunnels into. +pub(crate) fn block_on(f: F) -> F::Output { use std::sync::OnceLock; static RT: OnceLock = OnceLock::new(); let rt = RT.get_or_init(|| { @@ -659,36 +662,11 @@ pub(crate) fn credentials_path() -> Result { } pub(crate) fn store_session(path: &Path, s: &StoredSession) -> Result<()> { - let parent = path - .parent() - .ok_or_else(|| anyhow!("credentials path has no parent: {}", path.display()))?; - std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); - } - - let payload = serde_json::to_string_pretty(s)?; - std::fs::write(path, payload).with_context(|| format!("write {}", path.display()))?; - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) - .with_context(|| format!("chmod 0600 {}", path.display()))?; - } - Ok(()) + crate::config::write_private_json(path, s) } pub(crate) fn load_session(path: &Path) -> Result> { - match std::fs::read_to_string(path) { - Ok(s) if s.trim().is_empty() => Ok(None), - Ok(s) => Ok(Some(serde_json::from_str(&s).with_context(|| { - format!("decode credentials at {}", path.display()) - })?)), - Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), - Err(e) => Err(anyhow!("read {}: {e}", path.display())), - } + crate::config::read_private_json(path) } pub(crate) fn clear_session(path: &Path) -> Result<()> { diff --git a/crates/path-cli/src/cmd_resume.rs b/crates/path-cli/src/cmd_resume.rs index 163634fe..df64f011 100644 --- a/crates/path-cli/src/cmd_resume.rs +++ b/crates/path-cli/src/cmd_resume.rs @@ -7,9 +7,16 @@ //! `` is resolved in this order: //! 1. `https://` / `http://` URL → fetched via `pathbase-client`, //! cached unless `--no-cache`. -//! 2. `owner/repo/slug` shorthand → same Pathbase fetch flow. -//! 3. Existing file path → read directly. -//! 4. Otherwise treated as a cache id under `~/.toolpath/documents/`. +//! 2. `s3://` / `s3a://` / `file://` URL ending in `.json` → fetched +//! from object storage (the inverse of `path share --to`), cached +//! the same way. +//! 3. A **share destination** rather than a document — a bucket, a +//! prefix, or a directory — is listed and offered in a picker. A +//! shared document is always `.json`, so anything else naming +//! a place is somewhere to browse. +//! 4. `owner/repo/slug` shorthand → same Pathbase fetch flow. +//! 5. Existing file path → read directly. +//! 6. Otherwise treated as a cache id under `~/.toolpath/documents/`. //! //! ## Harness selection //! @@ -48,10 +55,15 @@ use crate::harness::Harness; #[derive(Args, Debug)] pub struct ResumeArgs { - /// Toolpath document to resume from. Accepted shapes: a Pathbase - /// URL (`https://host/owner/repo/slug`), a bare Pathbase shorthand - /// (`owner/repo/slug`), a path to a local toolpath JSON file, or a - /// cache id (e.g. `claude-abc`, `pathbase-foo-bar-baz`). + /// Toolpath document to resume from, or a share destination to pick + /// one out of. + /// + /// Accepted shapes: a Pathbase URL + /// (`https://host/owner/repo/slug`); an object-storage URL + /// (`s3://bucket/key.json`); a share destination — a bucket, a + /// prefix, or a folder — which is listed in a picker; a bare + /// Pathbase shorthand (`owner/repo/slug`); a path to a local + /// toolpath JSON file; or a cache id (e.g. `claude-abc`). pub input: String, /// Working directory to run the resumed harness from. Defaults to @@ -193,6 +205,42 @@ pub(crate) fn ensure_path_with_agent(g: &Graph) -> Result<&TPath> { Ok(path) } +/// Fetch a remote document, preferring an existing cache entry. +/// +/// `--force` skips the probe and re-fetches; `--no-cache` skips both the +/// probe AND the post-fetch write (still useful for ephemeral +/// environments). Callers supply the cache id up front because every +/// remote shape can compute it from the reference alone — that's what +/// makes the cache hit free of a round trip. +fn fetch_cached( + raw: &str, + cache_id: &str, + args: &ResumeArgs, + fetch: impl FnOnce() -> Result, +) -> Result { + if !args.force + && !args.no_cache + && let Ok(cache_path) = crate::cache::cache_path(cache_id) + && cache_path.exists() + { + let json = std::fs::read_to_string(&cache_path) + .with_context(|| format!("read {}", cache_path.display()))?; + eprintln!("Resolved {raw} → {cache_id} (cached)"); + return Graph::from_json(&json) + .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e)); + } + + let derived = fetch()?; + if !args.no_cache { + // force=true here: we either short-circuited above (cache miss) + // or the user explicitly passed --force, and either way we want + // the new bytes to land. + crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; + eprintln!("Resolved {raw} → {}", derived.cache_id); + } + Ok(derived.doc) +} + /// Resolve the user-supplied `` argument into a parsed `Graph` /// plus the source harness inferred from its single inline path (if /// any). See spec § "Input resolution" for the order. @@ -202,14 +250,31 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option enum Shape<'a> { PathbaseUrl(&'a str), PathbaseShorthand(&'a str), + ObjectStore(&'a str), + /// A share destination rather than a single document: list it + /// and let the user pick. + ObjectContainer(&'a str), FilePath(&'a str), CacheId(&'a str), } + // A shared document is always `.json`, so anything else that + // names a place — a bucket, a prefix, a directory — is a container + // to browse rather than a document to load. + let names_a_document = raw.trim_end_matches('/').ends_with(".json"); + let shape = if raw.starts_with("http://") || raw.starts_with("https://") { Shape::PathbaseUrl(raw) + } else if crate::store::looks_like_object_uri(raw) { + if names_a_document { + Shape::ObjectStore(raw) + } else { + Shape::ObjectContainer(raw) + } } else if looks_like_pathbase_shorthand(raw) { Shape::PathbaseShorthand(raw) + } else if std::path::Path::new(raw).is_dir() { + Shape::ObjectContainer(raw) } else if std::path::Path::new(raw).is_file() { Shape::FilePath(raw) } else { @@ -218,34 +283,28 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option let graph: Graph = match shape { Shape::PathbaseUrl(u) | Shape::PathbaseShorthand(u) => { - // Probe the local cache before going to the network. The cache - // id is purely a function of the parsed (owner, repo, id), so - // we can compute it without fetching. `--force` skips the probe - // and re-fetches; `--no-cache` skips both the probe AND the - // post-fetch write (still useful for ephemeral environments). + // The cache id is purely a function of the parsed (owner, + // repo, id), so we can probe the cache without fetching. let (_, ref_) = crate::derive::parse_pathbase_ref(u, args.url.as_deref())?; let cache_id = crate::cache::pathbase_cache_id(&ref_.owner, &ref_.repo, &ref_.id); - if !args.force - && !args.no_cache - && let Ok(cache_path) = crate::cache::cache_path(&cache_id) - && cache_path.exists() - { - let json = std::fs::read_to_string(&cache_path) - .with_context(|| format!("read {}", cache_path.display()))?; - eprintln!("Resolved {} → {} (cached)", raw, cache_id); - Graph::from_json(&json) - .map_err(|e| anyhow::anyhow!("cached toolpath document is invalid: {}", e))? - } else { - let derived = crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref())?; - if !args.no_cache { - // force=true here: we either short-circuited above - // (cache miss) or the user explicitly passed --force, - // and either way we want the new bytes to land. - crate::cache::write_cached(&derived.cache_id, &derived.doc, true)?; - eprintln!("Resolved {} → {}", raw, derived.cache_id); - } - derived.doc - } + fetch_cached(raw, &cache_id, args, || { + crate::derive::pathbase_fetch_to_doc(u, args.url.as_deref()) + })? + } + Shape::ObjectStore(u) => { + // Same shape as Pathbase: the cache id falls out of the URI + // alone, so an already-downloaded object costs no request. + let cache_id = crate::store::ObjectUri::parse(u)?.cache_id(); + fetch_cached(raw, &cache_id, args, || { + crate::derive::object_fetch_to_doc(u) + })? + } + Shape::ObjectContainer(c) => { + let picked = pick_from_destination(c)?; + let uri = picked.to_string(); + fetch_cached(&uri, &picked.cache_id(), args, || { + crate::derive::object_fetch_to_doc(&uri) + })? } Shape::FilePath(p) => { let json = std::fs::read_to_string(p).with_context(|| format!("read {}", p))?; @@ -271,6 +330,108 @@ pub(crate) fn resolve_input(args: &ResumeArgs) -> Result<(Graph, Option Ok((graph, harness)) } +/// List a share destination and let the user pick one of its documents. +/// +/// This is the other half of `path share`: sharing has a picker across +/// every harness, so resuming from where you shared to needs one too, +/// or a destination is a write-only hole you can only read out of by +/// already knowing a filename. +/// +/// Rows are built from object names alone — no downloads — which is +/// what legible names buy: browsing a hundred shared sessions costs one +/// list request. +fn pick_from_destination(raw: &str) -> Result { + let dest = crate::store::Destination::parse(raw)?; + let settings = crate::store::effective_settings()?; + let entries = dest.list(&settings)?; + + if entries.is_empty() { + anyhow::bail!("no shared documents in {dest}. `path share --to {dest}` puts one there."); + } + + // One document is not a choice worth making the user confirm. + if entries.len() == 1 { + let only = &entries[0]; + eprintln!("Resuming the only document in {dest}: {}", only.stem); + return Ok(only.uri.clone()); + } + + if !crate::fuzzy::available() { + eprintln!("{} documents in {dest}:", entries.len()); + for e in entries.iter().take(20) { + eprintln!(" {}", e.uri); + } + if entries.len() > 20 { + eprintln!(" … and {} more", entries.len() - 20); + } + anyhow::bail!("picking needs `fzf` on PATH and a TTY; pass a full location instead"); + } + + let lines: Vec = entries + .iter() + .enumerate() + .map(|(i, e)| format_object_row(i, e)) + .collect(); + let header = format!("resume a shared session from {dest}"); + let opts = crate::fuzzy::PickOptions { + with_nth: "2..", + prompt: "resume> ", + preview: None, + preview_window: "up:60%:wrap-word", + header: Some(&header), + tiebreak: "index", + multi: false, + }; + let line = match crate::fuzzy::pick(&lines, &opts)? { + crate::fuzzy::PickResult::Selected(v) => match v.into_iter().next() { + Some(l) => l, + None => std::process::exit(130), + }, + crate::fuzzy::PickResult::NoMatch => std::process::exit(1), + // Esc / Ctrl-C: deliberate cancel, same exit code `share` uses. + crate::fuzzy::PickResult::Cancelled => std::process::exit(130), + }; + let idx: usize = line + .split('\t') + .next() + .and_then(|i| i.parse().ok()) + .ok_or_else(|| anyhow::anyhow!("internal: failed to parse picker row"))?; + entries + .get(idx) + .map(|e| e.uri.clone()) + .ok_or_else(|| anyhow::anyhow!("internal: picker returned an out-of-range row")) +} + +/// `\t ` — a hidden index column so the +/// selection maps back to its entry without reparsing the display, and +/// a display built only from what listing already told us. +fn format_object_row(index: usize, entry: &crate::store::ObjectEntry) -> String { + let when = entry + .modified + .map(|t| t.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| " ".to_string()); + format!( + "{index}\t{when} {:>8} {}", + human_size(entry.size), + entry.stem + ) +} + +fn human_size(bytes: u64) -> String { + const UNITS: [&str; 4] = ["B", "KB", "MB", "GB"]; + let mut size = bytes as f64; + let mut unit = 0; + while size >= 1024.0 && unit + 1 < UNITS.len() { + size /= 1024.0; + unit += 1; + } + if unit == 0 { + format!("{bytes} B") + } else { + format!("{size:.1} {}", UNITS[unit]) + } +} + /// Probe `$PATH` (or `path_override`, for tests) for a given binary name. /// Cross-platform: on Windows, also tries `.exe`. pub(crate) fn binary_on_path(name: &str, path_override: Option<&std::path::Path>) -> bool { diff --git a/crates/path-cli/src/cmd_share.rs b/crates/path-cli/src/cmd_share.rs index d85b780f..5a3d6072 100644 --- a/crates/path-cli/src/cmd_share.rs +++ b/crates/path-cli/src/cmd_share.rs @@ -54,11 +54,135 @@ pub struct ShareArgs { #[arg(long)] pub project: Option, + /// Where to upload, overriding the configured default. + /// + /// One of: `pathbase`; an S3 bucket (`s3://bucket/prefix`, or an + /// S3-compatible endpoint configured via `path auth s3 login`); or + /// a folder (`~/traces`, `/srv/traces`, `file:///srv/traces`), + /// which needs no credentials at all. A value with no scheme is a + /// local path — spell a bucket `s3://…`. + /// + /// Without this flag the target comes from `$TOOLPATH_SHARE_TARGET`, + /// then `path target`, then Pathbase. For object storage the + /// document is named `--.json`, and the printed + /// location is what `path resume` takes. + #[arg(long, value_name = "TARGET")] + pub to: Option, + /// Skip writing the cache; derive in-memory only #[arg(long)] pub no_cache: bool, } +/// Where a share uploads to, with everything needed to get it there. +/// +/// Resolved once in [`run`] — before the picker and before any derive — +/// so a missing credential or an unparseable destination fails in +/// milliseconds instead of after the user has picked a session and paid +/// for a derivation. +enum ShareTarget { + Pathbase { + auth: crate::cmd_pathbase::AuthMode, + base_url: String, + upload: crate::cmd_export::PathbaseUploadArgs, + }, + Object { + dest: crate::store::Destination, + settings: crate::store::S3Settings, + }, +} + +impl ShareTarget { + /// Human-readable destination, for the picker header. + fn describe(&self) -> String { + match self { + ShareTarget::Pathbase { base_url, .. } => base_url.clone(), + ShareTarget::Object { dest, .. } => dest.to_string(), + } + } + + /// Send `body` (the document JSON for `cache_id`) to the target. + fn upload(self, body: &str, cache_id: &str, summary: &str) -> Result<()> { + match self { + ShareTarget::Pathbase { + auth, + base_url, + upload, + } => crate::cmd_export::run_pathbase_inner(auth, base_url, upload, body, summary), + ShareTarget::Object { dest, settings } => { + let uri = dest.uri_for(&crate::store::name_for_body(body, cache_id)); + uri.put(&settings, body.as_bytes())?; + println!("{uri}"); + eprintln!("Uploaded {summary} → {uri}"); + eprintln!("Resume it with: path resume {uri}"); + Ok(()) + } + } + } +} + +/// Decide *where* this share goes. Pure: no network, no credential +/// probe, no filesystem beyond reading the config — so it can run +/// first, before the harness scan and the picker, and a typo'd target +/// fails instantly instead of after the user has picked a session. +fn plan_target(args: &ShareArgs) -> Result<(crate::target::Target, crate::target::Origin)> { + // The Pathbase-only flags are a statement of intent strong enough + // to override an object-storage default — and to be an error when + // paired with an explicit object `--to`. + let pathbase_flags = args.anon + || args.repo.is_some() + || args.public + || args.url.is_some() + || args.name.is_some(); + let resolved = crate::target::resolve(args.to.as_deref())?; + crate::target::apply_pathbase_flags(resolved, pathbase_flags) +} + +/// Turn a planned target into one we can upload through: load S3 +/// settings, or probe Pathbase credentials. This is the part that can +/// touch the network, so it runs only once we know there's something to +/// upload. +fn open_target( + args: &ShareArgs, + planned: (crate::target::Target, crate::target::Origin), +) -> Result { + let (target, origin) = planned; + match target { + crate::target::Target::Object(dest) => { + let settings = crate::store::effective_settings()?; + crate::target::check_reachable( + &crate::target::Target::Object(dest.clone()), + &settings, + )?; + if origin != crate::target::Origin::Flag { + // The destination wasn't typed on this command line, so + // say where it came from before uploading to it. + eprintln!("Sharing to {dest} ({})", origin.describe()); + } + Ok(ShareTarget::Object { dest, settings }) + } + crate::target::Target::Pathbase => { + let upload = crate::cmd_export::PathbaseUploadArgs { + url: args.url.clone(), + anon: args.anon, + repo: args.repo.clone(), + name: args.name.clone(), + public: args.public, + }; + let base_url = crate::cmd_export::resolve_upload_base_url(&upload); + // `needs_auth` decides whether preflight can fall back to + // anon on credential failure. + let needs_auth = upload.repo.is_some() || upload.public || upload.name.is_some(); + let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload.anon, needs_auth)?; + Ok(ShareTarget::Pathbase { + auth, + base_url, + upload, + }) + } + } +} + /// One artifact surfaced by a provider — today always an agent session. /// Rows feed both the unified `share` picker and `p cache sync`. #[derive(Debug, Clone)] @@ -488,24 +612,17 @@ pub fn run(args: ShareArgs) -> Result<()> { anyhow::bail!("--session requires --harness"); } - // Build upload args + base URL once and reuse for both the explicit - // path and the picker path. `needs_auth` decides whether preflight - // can fall back to anon on credential failure. - let upload_args = crate::cmd_export::PathbaseUploadArgs { - url: args.url.clone(), - anon: args.anon, - repo: args.repo.clone(), - name: args.name.clone(), - public: args.public, - }; - let base_url = crate::cmd_export::resolve_upload_base_url(&upload_args); - let needs_auth = upload_args.repo.is_some() || upload_args.public || upload_args.name.is_some(); + // Decide where this is going before doing anything else. A bad + // `--to`, or Pathbase flags aimed at an object target, should cost + // nothing to discover — not a harness scan, and certainly not a + // session pick. + let planned = plan_target(&args)?; if let (Some(h), Some(session)) = (harness, &args.session) { - // 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); + // Explicit-args: probe credentials before derive so a + // credential failure doesn't waste the derive/cache work. + let target = open_target(&args, planned)?; + return share_explicit(h, session.as_str(), &args, target); } let cwd = std::env::current_dir()?; @@ -528,14 +645,18 @@ pub fn run(args: ShareArgs) -> Result<()> { anyhow::bail!("fzf unavailable; run `path import ` then `path export pathbase`"); } - // We have rows AND fzf available — now validate credentials before - // making the user pick a session. If preflight returns Anon (either - // explicit --anon, no creds + no auth flags, or auth probe failed - // and fell back), the picker still fires with that knowledge baked in. - let auth = crate::cmd_pathbase::preflight_auth(&base_url, upload_args.anon, needs_auth)?; + // We have rows AND fzf available — now probe credentials, before + // making the user pick a session. For Pathbase, if preflight + // returns Anon (either explicit --anon, no creds + no auth flags, or + // auth probe failed and fell back), the picker still fires with that + // knowledge baked in. + let target = open_target(&args, planned)?; let lines: Vec = rows.iter().map(format_picker_row).collect(); - let header = format!("share an agent session (Enter = upload to {base_url})"); + let header = format!( + "share an agent session (Enter = upload to {})", + target.describe() + ); let opts = crate::fuzzy::PickOptions { with_nth: "4", prompt: "share> ", @@ -578,13 +699,14 @@ pub fn run(args: ShareArgs) -> Result<()> { } else { None }, + to: args.to.clone(), no_cache: args.no_cache, }; // 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) + share_explicit(h, &session, &explicit, target) } fn bail_no_sessions( @@ -789,8 +911,7 @@ fn share_explicit( harness: ArtifactType, session: &str, args: &ShareArgs, - auth: crate::cmd_pathbase::AuthMode, - base_url: String, + target: ShareTarget, ) -> Result<()> { let project = match (harness.path_keyed(), args.project.as_ref()) { (true, Some(p)) => Some(p.to_string_lossy().into_owned()), @@ -820,14 +941,7 @@ fn share_explicit( harness.name() ); let summary = format!("{} session {}", harness.name(), cache_id); - let upload = crate::cmd_export::PathbaseUploadArgs { - url: args.url.clone(), - anon: args.anon, - repo: args.repo.clone(), - name: args.name.clone(), - public: args.public, - }; - return crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary); + return target.upload(&body, &cache_id, &summary); } let derived = derive_session(harness, project.as_deref(), session)?; @@ -856,14 +970,7 @@ fn share_explicit( } let body = derived.doc.to_json()?; - let upload = crate::cmd_export::PathbaseUploadArgs { - url: args.url.clone(), - anon: args.anon, - repo: args.repo.clone(), - name: args.name.clone(), - public: args.public, - }; - crate::cmd_export::run_pathbase_inner(auth, base_url, upload, &body, &summary) + target.upload(&body, &derived.cache_id, &summary) } /// Build the TSV line fed to the picker. Three hidden parser-only @@ -1367,4 +1474,160 @@ mod tests { assert!(!status.exists); } } + + // ── Share target ───────────────────────────────────────────────── + + fn share_args_to(dest: Option<&str>) -> ShareArgs { + ShareArgs { + url: None, + anon: false, + repo: None, + name: None, + public: false, + harness: None, + session: None, + project: None, + to: dest.map(str::to_string), + no_cache: false, + } + } + + /// Pin `$TOOLPATH_CONFIG_DIR` at an empty tempdir and clear the + /// share-target env var, so resolution can't pick up the + /// developer's real configuration. + fn with_empty_config(f: impl FnOnce(&Path) -> R) -> R { + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + let temp = TempDir::new().unwrap(); + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_dir = std::env::var_os(CONFIG_DIR_ENV); + let prev_target = std::env::var_os(crate::target::TARGET_ENV); + unsafe { + std::env::set_var(CONFIG_DIR_ENV, temp.path()); + std::env::remove_var(crate::target::TARGET_ENV); + } + let out = f(temp.path()); + unsafe { + match prev_dir { + Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), + None => std::env::remove_var(CONFIG_DIR_ENV), + } + match prev_target { + Some(v) => std::env::set_var(crate::target::TARGET_ENV, v), + None => std::env::remove_var(crate::target::TARGET_ENV), + } + } + out + } + + #[test] + fn a_folder_to_flag_routes_the_share_to_object_storage() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let target = with_empty_config(|_| { + open_target( + &share_args_to(Some(&dest)), + plan_target(&share_args_to(Some(&dest))).unwrap(), + ) + .unwrap() + }); + assert!(matches!(target, ShareTarget::Object { .. })); + assert_eq!(target.describe(), dest); + } + + #[test] + fn an_object_target_uploads_the_body_under_the_cache_id() { + let folder = TempDir::new().unwrap(); + let dest = format!("{}/traces", folder.path().display()); + let target = with_empty_config(|_| { + open_target( + &share_args_to(Some(&dest)), + plan_target(&share_args_to(Some(&dest))).unwrap(), + ) + .unwrap() + }); + + let body = r#"{"graph":{"id":"g"},"paths":[]}"#; + target.upload(body, "claude-abc", "claude session").unwrap(); + + let written = folder.path().join("traces/claude-abc.json"); + assert_eq!(std::fs::read_to_string(written).unwrap(), body); + } + + #[test] + fn no_flag_and_no_default_still_means_pathbase() { + let target = with_empty_config(|_| { + open_target( + &share_args_to(None), + plan_target(&share_args_to(None)).unwrap(), + ) + .unwrap() + }); + assert!(matches!(target, ShareTarget::Pathbase { .. })); + } + + #[test] + fn a_configured_default_routes_a_bare_share() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let target = with_empty_config(|_| { + crate::target::set_default(&crate::target::Target::parse(&dest).unwrap()).unwrap(); + open_target( + &share_args_to(None), + plan_target(&share_args_to(None)).unwrap(), + ) + .unwrap() + }); + assert!(matches!(target, ShareTarget::Object { .. })); + assert_eq!(target.describe(), dest); + } + + #[test] + fn pathbase_flags_beat_an_object_default() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let target = with_empty_config(|_| { + crate::target::set_default(&crate::target::Target::parse(&dest).unwrap()).unwrap(); + let args = ShareArgs { + anon: true, + ..share_args_to(None) + }; + open_target(&args, plan_target(&args).unwrap()).unwrap() + }); + assert!( + matches!(target, ShareTarget::Pathbase { .. }), + "--anon is a Pathbase-only option and must win over an S3 default" + ); + } + + #[test] + fn pathbase_flags_with_an_explicit_object_to_is_an_error() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let err = with_empty_config(|_| { + let args = ShareArgs { + public: true, + ..share_args_to(Some(&dest)) + }; + match plan_target(&args) { + Err(e) => e.to_string(), + Ok(_) => panic!("--public with an object --to must be rejected"), + } + }); + assert!(err.contains("--to pathbase"), "{err}"); + } + + #[test] + fn to_pathbase_forces_pathbase_over_an_object_default() { + let folder = TempDir::new().unwrap(); + let dest = folder.path().to_string_lossy().into_owned(); + let target = with_empty_config(|_| { + crate::target::set_default(&crate::target::Target::parse(&dest).unwrap()).unwrap(); + open_target( + &share_args_to(Some("pathbase")), + plan_target(&share_args_to(Some("pathbase"))).unwrap(), + ) + .unwrap() + }); + assert!(matches!(target, ShareTarget::Pathbase { .. })); + } } diff --git a/crates/path-cli/src/cmd_target.rs b/crates/path-cli/src/cmd_target.rs new file mode 100644 index 00000000..7210d232 --- /dev/null +++ b/crates/path-cli/src/cmd_target.rs @@ -0,0 +1,91 @@ +//! `path target` — read or set where `path share` uploads. +//! +//! A top-level verb rather than a flag on `share` or a subcommand of +//! `auth`: it's a persistent setting, not a per-call option, and it is +//! not authentication. "Where do my shares go?" is a question people +//! ask without knowing which command owns the answer, so the command is +//! named after the thing itself. +//! +//! The setting and its resolution live in [`crate::target`]; this +//! module is only the CLI surface over them. + +#![cfg(not(target_os = "emscripten"))] + +use anyhow::Result; +use clap::Args; + +use crate::store; +use crate::target::{self, Target}; + +#[derive(Args, Debug)] +pub struct TargetArgs { + /// Where `path share` should upload: `pathbase`, an S3 bucket + /// (`s3://bucket/prefix`), or a folder (`~/Dropbox/traces`, + /// `/srv/traces`). Omit to print the current target and where it + /// came from. + #[arg(index = 1, value_name = "TARGET")] + pub target: Option, + + /// Forget the configured target, falling back to Pathbase + #[arg(long, conflicts_with = "target")] + pub clear: bool, + + /// Store the target without checking that it works. For a bucket + /// you haven't created yet, or when you're offline. + #[arg(long, conflicts_with = "clear")] + pub no_verify: bool, +} + +pub fn run(args: TargetArgs) -> Result<()> { + if args.clear { + let path = target::clear_default()?; + println!("Share target cleared ({}).", path.display()); + println!("`path share` now uploads to Pathbase."); + return Ok(()); + } + + let Some(spec) = args.target else { + return print_current(); + }; + + let parsed = Target::parse(&spec)?; + + // Prove it works before storing it. A target set once and used many + // times is exactly the thing worth checking at the moment it's + // chosen — storing an unusable one just defers the failure to the + // middle of a share, where it costs a session pick and a + // derivation. `--no-verify` is the escape hatch for the cases where + // the user genuinely knows better. + if !args.no_verify { + let settings = store::effective_settings()?; + target::verify(&parsed, &settings)?; + } + + let path = target::set_default(&parsed)?; + println!("Share target set to {parsed}"); + println!(" stored in: {}", path.display()); + if args.no_verify { + println!(" (not verified)"); + } + Ok(()) +} + +/// Answer "where does my next share go?" in one command, including why. +fn print_current() -> Result<()> { + let (stored, path) = target::default_target()?; + match &stored { + Some(t) => println!("Share target: {t} (from {})", path.display()), + None => println!("No share target configured."), + } + // The stored value isn't the whole story — an env var or the + // built-in fallback may be what actually applies right now. + println!("In effect now: {}", target::describe_effective()?); + if stored.is_none() { + println!(); + println!("Set one with:"); + println!(" path target s3://my-bucket/traces"); + println!(" path target ~/Dropbox/toolpath # a folder needs no credentials"); + println!(" path target pathbase"); + } + Ok(()) +} diff --git a/crates/path-cli/src/config.rs b/crates/path-cli/src/config.rs index 86a923f8..7b1c596d 100644 --- a/crates/path-cli/src/config.rs +++ b/crates/path-cli/src/config.rs @@ -29,6 +29,52 @@ pub(crate) fn config_dir() -> Result { Ok(PathBuf::from(home).join(CONFIG_DIR_NAME)) } +/// Write `value` as pretty JSON to a user-private file: the parent +/// directory is created `0700` and the file itself `0600`. +/// +/// Every credential-bearing blob under the config dir goes through +/// here (Pathbase sessions, S3 settings) so the permissions story is +/// stated once instead of re-derived per call site. +pub(crate) fn write_private_json( + path: &std::path::Path, + value: &T, +) -> Result<()> { + let parent = path + .parent() + .ok_or_else(|| anyhow!("config path has no parent: {}", path.display()))?; + std::fs::create_dir_all(parent).map_err(|e| anyhow!("create {}: {e}", parent.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let _ = std::fs::set_permissions(parent, std::fs::Permissions::from_mode(0o700)); + } + + let payload = serde_json::to_string_pretty(value)?; + std::fs::write(path, payload).map_err(|e| anyhow!("write {}: {e}", path.display()))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) + .map_err(|e| anyhow!("chmod 0600 {}: {e}", path.display()))?; + } + Ok(()) +} + +/// Read a JSON blob written by [`write_private_json`]. A missing or +/// empty file is `Ok(None)` — "not configured", not an error. +pub(crate) fn read_private_json( + path: &std::path::Path, +) -> Result> { + match std::fs::read_to_string(path) { + Ok(s) if s.trim().is_empty() => Ok(None), + Ok(s) => Ok(Some( + serde_json::from_str(&s).map_err(|e| anyhow!("decode {}: {e}", path.display()))?, + )), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(anyhow!("read {}: {e}", path.display())), + } +} + /// Shared lock for tests that manipulate `$TOOLPATH_CONFIG_DIR`. Every /// test module that calls `set_var` / `remove_var` on this env var should /// grab this lock first, otherwise parallel tests race and clobber each diff --git a/crates/path-cli/src/derive.rs b/crates/path-cli/src/derive.rs index d9b09e89..5f767846 100644 --- a/crates/path-cli/src/derive.rs +++ b/crates/path-cli/src/derive.rs @@ -352,6 +352,28 @@ pub(crate) fn derive_pi_session_with( }) } +/// Download a toolpath document from object storage (`s3://bucket/key`, +/// `file:///dir/doc.json`) and parse it. The counterpart to +/// `path share --s3`; used by `path p import s3` and `path resume +/// s3://…`. +/// +/// `provenance` is `None` for the same reason Pathbase downloads leave +/// it unset: the sync manifest tracks local artifact sources, and a +/// remote object isn't one. +#[cfg(not(target_os = "emscripten"))] +pub(crate) fn object_fetch_to_doc(target: &str) -> Result { + let uri = crate::store::ObjectUri::parse(target)?; + let cfg = crate::store::effective_settings()?; + let body = uri.get(&cfg)?; + let doc = Graph::from_json(&body) + .map_err(|e| anyhow::anyhow!("{uri} is not a toolpath document: {e}"))?; + Ok(DerivedDoc { + cache_id: uri.cache_id(), + doc, + provenance: None, + }) +} + /// Fetch a Pathbase ref (`https://host/u/owner/repos/repo/graphs/` /// URL or bare `owner/repo/` triple) and parse it as a toolpath /// document. Used by `path import pathbase` and `path resume `. diff --git a/crates/path-cli/src/lib.rs b/crates/path-cli/src/lib.rs index 14ed9bba..0fc1fb16 100644 --- a/crates/path-cli/src/lib.rs +++ b/crates/path-cli/src/lib.rs @@ -1,4 +1,6 @@ pub mod artifact; +#[cfg(not(target_os = "emscripten"))] +mod aws_creds; mod cache; #[cfg(not(target_os = "emscripten"))] mod cmd_auth; @@ -24,6 +26,8 @@ pub mod cmd_resume; mod cmd_share; #[cfg(not(target_os = "emscripten"))] mod cmd_show; +#[cfg(not(target_os = "emscripten"))] +mod cmd_target; mod cmd_track; mod cmd_validate; mod config; @@ -38,7 +42,11 @@ mod query; mod schema; #[cfg(all(not(target_os = "emscripten"), feature = "embedded-picker"))] mod skim_picker; +#[cfg(not(target_os = "emscripten"))] +mod store; mod sync; +#[cfg(not(target_os = "emscripten"))] +mod target; mod term; use anyhow::Result; @@ -79,7 +87,8 @@ enum Commands { #[arg(long)] ansi: bool, }, - /// Share an agent session to Pathbase via an interactive picker + /// Share an agent session via an interactive picker — to Pathbase, + /// an S3 bucket, or a folder (see `path target`) #[cfg(not(target_os = "emscripten"))] Share { #[command(flatten)] @@ -104,12 +113,19 @@ enum Commands { #[command(flatten)] args: cmd_kind::KindArgs, }, - /// Manage Pathbase credentials for trace uploads + /// Manage upload credentials: Pathbase, or S3 under `auth s3` #[cfg(not(target_os = "emscripten"))] Auth { #[command(subcommand)] op: cmd_auth::AuthOp, }, + /// Show or set where `path share` uploads: Pathbase, an S3 bucket, + /// or a folder + #[cfg(not(target_os = "emscripten"))] + Target { + #[command(flatten)] + args: cmd_target::TargetArgs, + }, /// Plumbing: lower-level operations on documents and sources /// (import, export, cache, list, render, merge, validate, derive, /// project, incept, track, query) @@ -142,6 +158,8 @@ pub fn run() -> Result<()> { Commands::Kind { args } => cmd_kind::run(args), #[cfg(not(target_os = "emscripten"))] Commands::Auth { op } => cmd_auth::run(op), + #[cfg(not(target_os = "emscripten"))] + Commands::Target { args } => cmd_target::run(args), Commands::P { command } => cmd_p::run(command, cli.pretty), } } diff --git a/crates/path-cli/src/store.rs b/crates/path-cli/src/store.rs new file mode 100644 index 00000000..542a0c7c --- /dev/null +++ b/crates/path-cli/src/store.rs @@ -0,0 +1,1334 @@ +//! Object-storage destinations for share and resume. +//! +//! Transport is [`object_store`], so one code path covers real AWS S3, +//! any S3-compatible endpoint (Cloudflare R2, MinIO, Ceph, Backblaze +//! B2), and a plain local directory via `file://`. A folder is a +//! first-class destination, not a testing affordance: `path target +//! ~/Dropbox/traces` is a complete setup, needing no credentials at +//! all. It is also what the tests round-trip against, so share and +//! resume are exercised end-to-end without a network. +//! +//! The module owns two separable things: +//! +//! 1. [`Destination`] / [`ObjectUri`] / [`ObjectName`] — *where* a +//! document goes and what it's called. Pure URL parsing and naming; +//! no credentials involved. Where a document lands is a function of +//! the destination and the document itself, nothing else. +//! 2. [`S3Settings`] — *how to reach* an `s3://` destination: region, +//! endpoint, addressing style, and credentials, persisted at +//! `~/.toolpath/s3.json` by `path auth s3 login`. +//! +//! Keeping those apart is what lets `--to ~/traces` skip the whole +//! credential story, and lets one stored credential serve any number +//! of buckets. +//! +//! Credentials are handed to `object_store` as config options rather +//! than resolved here. When none are configured, the AWS credential +//! chain (env, EC2/ECS instance metadata, web identity) still applies — +//! so an EC2 box with an instance role needs no `path auth s3 login` +//! at all. + +use anyhow::{Context, Result, anyhow, bail}; +use object_store::{ObjectStore, ObjectStoreExt}; +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; +use url::Url; + +use crate::config::config_dir; + +pub(crate) const S3_CONFIG_FILE: &str = "s3.json"; +pub(crate) const DEFAULT_REGION: &str = "us-east-1"; + +/// URL schemes routed to object storage. Deliberately narrower than +/// what `object_store` can parse: `http`/`https` belong to Pathbase in +/// every command that shares this dispatch, `gs://` / `az://` would +/// need feature flags we don't compile in, and `memory://` is a fresh +/// per-process store — anything "shared" there is gone before the +/// command exits, so accepting it would only waste someone's afternoon. +const SCHEMES: [&str; 3] = ["s3", "s3a", "file"]; + +// ── S3 connection settings ────────────────────────────────────────────── + +/// The blob persisted at `~/.toolpath/s3.json` (0600). +/// +/// Connection and credentials only — deliberately *not* a destination. +/// The bucket and prefix live in the share target +/// ([`crate::target`]), so there is exactly one answer to +/// "where does my next share go?", and so one stored credential can +/// serve `--to s3://a/x` and `--to s3://b/y` alike. +/// +/// Every field is optional so a partial configuration is legal: a user +/// whose credentials come from the environment (CI, an EC2 instance +/// role) may store only `region`/`endpoint`, or nothing at all. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct S3Settings { + #[serde(default, skip_serializing_if = "Option::is_none")] + pub region: Option, + /// Custom endpoint (`https://…`) for S3-compatible services such as + /// Cloudflare R2 or MinIO. Absent means real AWS S3. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub endpoint: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub access_key_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub secret_access_key: Option, + /// Temporary-credential token (STS / assumed role). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_token: Option, + /// Use virtual-hosted addressing (`bucket.host/key`) instead of + /// path style (`host/bucket/key`). Unset lets `object_store` pick: + /// path style, which every S3-compatible endpoint accepts. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub virtual_hosted_style: Option, + /// AWS profile to resolve credentials from, when you don't want the + /// `AWS_PROFILE` / `[default]` answer. Storing a profile name is + /// very different from storing a key: it's a pointer to credentials + /// the AWS tooling already manages, so it can't go stale and it + /// costs nothing at rest. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub profile: Option, +} + +/// The credential resolution this settings blob implies. +/// +/// `profile` is threaded through so `--profile` on a command reaches +/// the resolver; everything else comes from the ambient AWS setup. +impl S3Settings { + pub(crate) fn resolved_credentials(&self) -> Option { + self.resolve_real().ok() + } + + /// [`resolved_credentials`](Self::resolved_credentials), keeping the + /// error. Anything *reporting* on credentials wants the reason — + /// "no such profile" is the whole answer, and swallowing it leaves + /// the user with nothing to act on. + pub(crate) fn resolve_real(&self) -> Result { + self.resolve_with(&crate::aws_creds::Env { + home: std::env::var_os("HOME").map(PathBuf::from), + var: &|k| std::env::var(k).ok(), + aws_cli: &crate::aws_creds::run_aws_cli, + }) + } + + /// [`resolved_credentials`](Self::resolved_credentials) against an + /// injected environment, and propagating the error so callers that + /// want to *report* a failure (rather than fall through to the + /// instance chain) can. + pub(crate) fn resolve_with( + &self, + env: &crate::aws_creds::Env<'_>, + ) -> Result { + let stored = match (&self.access_key_id, &self.secret_access_key) { + (Some(k), Some(s)) => Some(crate::aws_creds::Credentials { + access_key_id: k.clone(), + secret_access_key: s.clone(), + session_token: self.session_token.clone(), + }), + _ => None, + }; + crate::aws_creds::resolve(stored, self.profile.as_deref(), env) + } +} + +pub(crate) fn config_path() -> Result { + Ok(config_dir()?.join(S3_CONFIG_FILE)) +} + +pub(crate) fn load_stored(path: &std::path::Path) -> Result> { + crate::config::read_private_json(path) +} + +pub(crate) fn store(path: &std::path::Path, cfg: &S3Settings) -> Result<()> { + crate::config::write_private_json(path, cfg) +} + +pub(crate) fn clear(path: &std::path::Path) -> Result<()> { + match std::fs::remove_file(path) { + Ok(()) => Ok(()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(anyhow!("remove {}: {e}", path.display())), + } +} + +/// The stored settings with environment variables filling any gap. +/// +/// Precedence is stored-then-env, not env-then-stored: the point of +/// `path auth s3 login` is that what you configured is what you get. +/// Env vars are the fallback for environments that never ran `login` +/// (CI, containers), and they use the conventional AWS names so an +/// already-configured shell just works. +/// +/// Recognized: `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, +/// `AWS_SESSION_TOKEN`, `AWS_REGION` (then `AWS_DEFAULT_REGION`), and +/// `AWS_ENDPOINT_URL_S3` (then `AWS_ENDPOINT_URL`). +pub(crate) fn effective_settings() -> Result { + let stored = load_stored(&config_path()?)?.unwrap_or_default(); + Ok(merge_env(stored, |k| std::env::var(k).ok())) +} + +/// [`effective_settings`] with the environment injected, so tests don't +/// have to mutate process-global state. +pub(crate) fn merge_env Option>(mut cfg: S3Settings, env: F) -> S3Settings { + let first = |keys: &[&str]| -> Option { + keys.iter() + .find_map(|k| env(k).filter(|v| !v.trim().is_empty())) + }; + cfg.access_key_id = cfg.access_key_id.or_else(|| first(&["AWS_ACCESS_KEY_ID"])); + cfg.secret_access_key = cfg + .secret_access_key + .or_else(|| first(&["AWS_SECRET_ACCESS_KEY"])); + cfg.session_token = cfg.session_token.or_else(|| first(&["AWS_SESSION_TOKEN"])); + cfg.region = cfg + .region + .or_else(|| first(&["AWS_REGION", "AWS_DEFAULT_REGION"])); + cfg.endpoint = cfg + .endpoint + .or_else(|| first(&["AWS_ENDPOINT_URL_S3", "AWS_ENDPOINT_URL"])); + cfg +} + +/// Settings as `object_store` key/value options. Unrecognized keys are +/// ignored by `parse_url_opts`, so the same list is safe to pass for a +/// `file://` URL as for `s3://`. +fn store_options(cfg: &S3Settings) -> Vec<(&'static str, String)> { + fn push(opts: &mut Vec<(&'static str, String)>, k: &'static str, v: &Option) { + if let Some(v) = v.as_deref().map(str::trim).filter(|v| !v.is_empty()) { + opts.push((k, v.to_string())); + } + } + + let mut opts: Vec<(&'static str, String)> = Vec::new(); + + // Credentials come from `resolve_credentials`, not straight off + // `cfg` — a stored key is only one of the places they can live, and + // the common laptop case is an AWS profile we had to go find. + let resolved = cfg.resolved_credentials(); + if let Some(c) = resolved.as_ref().and_then(|r| r.credentials.as_ref()) { + opts.push(("aws_access_key_id", c.access_key_id.clone())); + opts.push(("aws_secret_access_key", c.secret_access_key.clone())); + if let Some(t) = &c.session_token { + opts.push(("aws_session_token", t.clone())); + } + } + + push(&mut opts, "aws_endpoint", &cfg.endpoint); + let region = cfg + .region + .clone() + .or_else(|| resolved.as_ref().and_then(|r| r.region.clone())) + .unwrap_or_else(|| DEFAULT_REGION.to_string()); + opts.push(("aws_region", region)); + + if let Some(v) = cfg.virtual_hosted_style { + opts.push(("aws_virtual_hosted_style_request", v.to_string())); + } + // A plaintext endpoint is a deliberate choice (MinIO on localhost, + // a test fixture); object_store refuses http:// unless told. + if cfg + .endpoint + .as_deref() + .is_some_and(|e| e.starts_with("http://")) + { + opts.push(("aws_allow_http", "true".to_string())); + } + opts +} + +// ── Locations ─────────────────────────────────────────────────────────── + +/// A single object in object storage. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObjectUri { + url: Url, +} + +impl std::fmt::Display for ObjectUri { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&friendly(&self.url)) + } +} + +/// True for anything `path resume` / `p import` should route to object +/// storage rather than to Pathbase or the local cache. +pub(crate) fn looks_like_object_uri(s: &str) -> bool { + SCHEMES.iter().any(|p| s.starts_with(&format!("{p}://"))) +} + +impl ObjectUri { + /// Parse a full object reference. A container with no key names a + /// place, not a document, so it's rejected here — the share side + /// goes through [`Destination`] instead. + pub(crate) fn parse(raw: &str) -> Result { + let url = parse_location(raw)?; + if url.path().trim_matches('/').is_empty() { + bail!( + "`{raw}` names a location but no object key \ + (expected s3://bucket/path/to/doc.json)" + ); + } + Ok(ObjectUri { url }) + } + + /// The cache id a download of this object lands at, e.g. + /// `s3-my-bucket-traces_claude-abc`. + pub(crate) fn cache_id(&self) -> String { + let source = match self.url.scheme() { + "s3a" => "s3", + other => other, + }; + let host = self.url.host_str().unwrap_or_default(); + let key = self.url.path().trim_matches('/'); + let inner = if host.is_empty() { + key.to_string() + } else { + format!("{host}-{key}") + }; + crate::cache::make_id(source, &inner) + } + + /// Download the object as UTF-8 text. + pub(crate) fn get(&self, cfg: &S3Settings) -> Result { + let (store, path) = open(&self.url, cfg)?; + let bytes = block_on(async { + let result = store.get(&path).await?; + result.bytes().await + }) + .map_err(|e| explain_location(e, "read", &self.to_string()))?; + String::from_utf8(bytes.to_vec()).with_context(|| format!("{self} is not valid UTF-8")) + } + + /// Upload `body` to the object, overwriting any existing one. + /// + /// Overwrite is intentional: the object name is a pure function of + /// the document, so re-sharing a session that has grown replaces + /// its own object rather than accumulating near-duplicates. + pub(crate) fn put(&self, cfg: &S3Settings, body: &[u8]) -> Result<()> { + let (store, path) = open(&self.url, cfg)?; + let payload = object_store::PutPayload::from(body.to_vec()); + block_on(store.put(&path, payload)) + .map(|_| ()) + .map_err(|e| explain_location(e, "write", &self.to_string())) + } +} + +fn open(url: &Url, cfg: &S3Settings) -> Result<(Box, object_store::path::Path)> { + open_with(url, cfg, Vec::new()) +} + +fn open_with( + url: &Url, + cfg: &S3Settings, + extra: Vec<(&'static str, String)>, +) -> Result<(Box, object_store::path::Path)> { + let mut opts = store_options(cfg); + opts.extend(extra); + object_store::parse_url_opts(url, opts).with_context(|| format!("open {}", friendly(url))) +} + +/// Open a store for a preflight check rather than for real work. +/// +/// Uploads keep `object_store`'s defaults — a big session over a slow +/// link is not a failure — but a check exists to give a fast answer. +/// Per-request timeouts alone don't bound it: the default ten retries +/// multiply them, so a hanging endpoint could stall a command whose +/// whole job is to record a preference. Cap the retries too. +fn open_for_check( + url: &Url, + cfg: &S3Settings, +) -> Result<(Box, object_store::path::Path)> { + let timeouts = vec![ + ("aws_connect_timeout", "5s".to_string()), + ("aws_timeout", "15s".to_string()), + ]; + if !matches!(url.scheme(), "s3" | "s3a") { + // Local has neither retries nor a network to wait on. + return open_with(url, cfg, timeouts); + } + + // `parse_url_opts` has no key for retry policy, so build the S3 + // store directly. `with_url` does the same bucket/region parsing + // `parse_url_opts` would. + let mut builder = object_store::aws::AmazonS3Builder::new().with_url(url.as_str()); + for (key, value) in store_options(cfg).into_iter().chain(timeouts) { + if let Ok(parsed) = key.parse() { + builder = builder.with_config(parsed, value); + } + } + let store = builder + .with_retry(object_store::RetryConfig { + max_retries: 2, + retry_timeout: std::time::Duration::from_secs(20), + ..Default::default() + }) + .build() + .with_context(|| format!("open {}", friendly(url)))?; + let path = object_store::path::Path::parse(url.path()) + .with_context(|| format!("parse {}", friendly(url)))?; + Ok((Box::new(store), path)) +} + +/// Explain a failed [`Destination::verify`] in terms of what the user +/// was trying to do — designate a place to share to — and how to get +/// past it if they know better than we do. +fn explain_verify(err: object_store::Error, dest: &Destination) -> anyhow::Error { + let detail = match &err { + e if is_unreachable(e) => { + "couldn't reach the endpoint. Check the URL and your network.".to_string() + } + e if is_missing_container(e) => format!( + "no such bucket: {}. Check the name, or the endpoint if this isn't AWS.", + dest.base.host_str().unwrap_or_default() + ), + object_store::Error::Unauthenticated { .. } => { + "S3 rejected the credentials. Run `path auth s3 login` to store working ones." + .to_string() + } + object_store::Error::PermissionDenied { .. } => { + "the credentials can reach the bucket but aren't allowed to write to it. \ + Check the bucket policy for `s3:PutObject` on this prefix." + .to_string() + } + e => terse(e), + }; + anyhow!( + "can't write to {dest}: {detail}\n\ + Pass --no-verify to store it anyway (e.g. the bucket doesn't exist yet, \ + or you're offline)." + ) +} + +/// True when the request never reached a server: DNS, refused +/// connection, timeout. Distinguished from an S3-level rejection +/// because the fix is completely different — check the URL, not the +/// policy. +fn is_unreachable(err: &object_store::Error) -> bool { + let msg = err.to_string(); + msg.contains("error sending request") + || msg.contains("Connection refused") + || msg.contains("operation timed out") + || msg.contains("dns error") +} + +/// Strip `object_store`'s internals out of an error message. +/// +/// Its transport errors carry a retry epilogue — "after 10 retries, +/// max_retries: 10, retry_timeout: 180s" — plus a `Generic S3 error:` +/// prefix. Neither tells a user anything actionable, and both bury the +/// part that does. +fn terse(err: &object_store::Error) -> String { + let msg = err.to_string(); + let msg = msg.split(", after ").next().unwrap_or(&msg); + msg.trim_start_matches("Generic S3 error: ") + .trim_end_matches([' ', '-']) + .to_string() +} + +/// Where `path share` writes when the target is object storage: a +/// bucket-or-folder base that object keys hang off. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Destination { + base: Url, +} + +impl std::fmt::Display for Destination { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(friendly(&self.base).trim_end_matches('/')) + } +} + +/// One object found by [`Destination::list`]. +#[derive(Debug, Clone)] +pub(crate) struct ObjectEntry { + pub uri: ObjectUri, + /// Filename without the `.json` extension — for legible names this + /// is `--`, which is the whole point. + pub stem: String, + pub size: u64, + pub modified: Option>, +} + +impl Destination { + /// Parse a user-supplied destination. See [`parse_location`] for + /// how a scheme-less value is read. + pub(crate) fn parse(raw: &str) -> Result { + Ok(Destination { + base: parse_location(raw)?, + }) + } + + /// The `.json` objects sitting directly under this destination, + /// newest first. + /// + /// Deliberately non-recursive: a destination is a place you share + /// *to*, so its immediate contents are what a picker should offer. + /// Nothing is downloaded — legible object names carry enough for a + /// picker row, which is exactly why they're worth the length. + pub(crate) fn list(&self, cfg: &S3Settings) -> Result> { + let (store, prefix) = open(&self.base, cfg)?; + let listed = block_on(store.list_with_delimiter(Some(&prefix))) + .map_err(|e| explain_location(e, "list", &friendly(&self.base)))?; + + let mut out: Vec = listed + .objects + .into_iter() + .filter(|m| m.location.as_ref().ends_with(".json")) + .map(|m| { + let name = m + .location + .filename() + .unwrap_or_default() + .trim_end_matches(".json") + .to_string(); + let mut url = self.base.clone(); + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}/{name}.json")); + ObjectEntry { + uri: ObjectUri { url }, + stem: name, + size: m.size, + modified: Some(m.last_modified), + } + }) + .collect(); + // Newest first: the session you want is nearly always recent. + out.sort_by(|a, b| b.modified.cmp(&a.modified).then(a.stem.cmp(&b.stem))); + Ok(out) + } + + /// Prove a share to this destination will actually work, by doing + /// what a share does: write a small object, then remove it. + /// + /// This is the *configuration-time* check, and it is deliberately a + /// real write. A listing tells you about `s3:ListBucket`, which is + /// not the permission a share needs; a credential probe tells you + /// the keys parse. Only a write tells you the thing the user is + /// actually asking — "can I send my sessions here?" — and the + /// moment they designate a destination is the cheapest possible + /// time to answer it. Anything less is theater that defers the + /// failure to the middle of a share. + /// + /// The probe object is named to be obviously ours, and removed + /// afterwards. A credential that can write but not delete leaves it + /// behind; that's reported, not fatal, because writing was the + /// thing being tested. + pub(crate) fn verify(&self, cfg: &S3Settings) -> Result<()> { + const PROBE: &str = ".toolpath-access-check"; + const BODY: &[u8] = b"toolpath access check\n"; + + let mut url = self.base.clone(); + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}/{PROBE}")); + + // Bound the wait: a wrong endpoint should fail in seconds, not + // hang a command whose whole job is to record a preference. + let (store, path) = open_for_check(&url, cfg)?; + block_on(store.put(&path, object_store::PutPayload::from(BODY.to_vec()))) + .map_err(|e| explain_verify(e, self))?; + + if let Err(e) = block_on(store.delete(&path)) { + eprintln!( + "note: wrote and left behind {}/{PROBE} — the credentials can write \ + but not delete ({e})", + self + ); + } + Ok(()) + } + + /// Cheap reachability check for the moment *before* an upload, when + /// a real write would be redundant — the upload itself is about to + /// happen and will report its own failure. + /// + /// Only *conclusive* failures are errors. A credential that can + /// write but not list is normal, and so is an empty or missing + /// prefix. This exists to catch the one case worth catching before + /// a derivation: a typo'd bucket or endpoint. + pub(crate) fn probe(&self, cfg: &S3Settings) -> Result<()> { + // Local destinations create themselves on write, and a bad path + // fails with a clear OS error. Nothing to preflight. + if self.is_local() { + return Ok(()); + } + let (store, prefix) = open_for_check(&self.base, cfg)?; + match block_on(store.list_with_delimiter(Some(&prefix))) { + Ok(_) | Err(object_store::Error::NotFound { .. }) => Ok(()), + // A write-only credential can't list. Not a problem. + Err(object_store::Error::PermissionDenied { .. }) => Ok(()), + Err(e) if is_missing_container(&e) => Err(anyhow!( + "no such bucket: {}. Check the name, or the endpoint if this isn't AWS.", + self.base.host_str().unwrap_or_default() + )), + Err(e @ object_store::Error::Unauthenticated { .. }) => Err(anyhow!( + "S3 rejected the stored credentials for {self}: {e}. \ + Run `path auth s3 login` to replace them." + )), + // Anything else — a transient network blip, an unusual + // policy — is not worth blocking a share over. The upload + // itself will report it properly if it's real. + Err(_) => Ok(()), + } + } + + /// The canonical form to persist: always a URL, never a bare path, + /// so a stored default can't be re-read relative to a different cwd. + pub(crate) fn as_url(&self) -> &str { + self.base.as_str() + } + + /// True for a plain local folder — the case that needs no + /// credentials, and so no `path auth s3 login`. + pub(crate) fn is_local(&self) -> bool { + self.base.scheme() == "file" + } + + /// The object a document with this name lands at. + pub(crate) fn uri_for(&self, name: &ObjectName) -> ObjectUri { + let mut url = self.base.clone(); + let base_path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{base_path}/{}.json", name.0)); + ObjectUri { url } + } +} + +// ── Naming ────────────────────────────────────────────────────────────── + +/// What a shared document is called in the destination. +/// +/// `--`, e.g. +/// `2026-08-07-add-s3-support-to-share-claude-6f2a1c9e`. +/// +/// Two requirements pull in opposite directions and both are load-bearing: +/// +/// - **Stable.** Every component is a pure function of the document, so +/// re-sharing a session that has grown overwrites its own object +/// instead of leaving a trail of near-duplicates. +/// - **Legible.** A destination is a folder someone will open, or a +/// bucket someone will page through. `claude-6f2a1c9e.json` tells +/// them nothing; the date sorts chronologically under a plain +/// lexicographic listing, and the slug says which session it is. +/// +/// Legibility also buys the picker: `path resume ` builds +/// its rows from names alone, so browsing a hundred shared sessions +/// costs one list request and zero downloads. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct ObjectName(String); + +impl std::fmt::Display for ObjectName { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.0) + } +} + +impl ObjectName { + /// Longest slug we'll put in a name. Long enough to recognize a + /// session, short enough that the cache id stays visible in a + /// terminal-width listing. + const SLUG_MAX: usize = 48; + + pub(crate) fn new(cache_id: &str, date: Option<&str>, title: Option<&str>) -> Self { + let mut parts: Vec = Vec::new(); + if let Some(d) = date.map(slugify).filter(|d| !d.is_empty()) { + parts.push(d); + } + if let Some(t) = title.map(slugify).filter(|t| !t.is_empty()) { + parts.push(truncate_slug(&t, Self::SLUG_MAX)); + } + parts.push(slugify(cache_id)); + ObjectName(parts.join("-")) + } + + /// The name for a document with no usable metadata — the cache id + /// alone, which is what the whole scheme degrades to. + #[cfg(test)] + pub(crate) fn bare(cache_id: &str) -> Self { + Self::new(cache_id, None, None) + } +} + +/// Lowercase, ASCII-alphanumeric, single dashes, no leading/trailing +/// dash. Deliberately lossy — this is a filename, not a title. +fn slugify(raw: &str) -> String { + let mut out = String::with_capacity(raw.len()); + let mut pending_dash = false; + for ch in raw.chars() { + if ch.is_ascii_alphanumeric() { + if pending_dash && !out.is_empty() { + out.push('-'); + } + pending_dash = false; + out.push(ch.to_ascii_lowercase()); + } else { + pending_dash = true; + } + } + out +} + +/// Truncate on a dash boundary so a name never ends mid-word. +fn truncate_slug(slug: &str, max: usize) -> String { + if slug.len() <= max { + return slug.to_string(); + } + let cut = &slug[..max]; + match cut.rfind('-') { + Some(i) if i > 0 => cut[..i].to_string(), + _ => cut.to_string(), + } +} + +/// Name a document for a destination, reading the date and topic out of +/// the document itself so `share` and `p export object` agree without +/// either of them having to know where the document came from. +pub(crate) fn name_for(doc: &toolpath::v1::Graph, cache_id: &str) -> ObjectName { + let path = doc.paths.iter().find_map(|p| match p { + toolpath::v1::PathOrRef::Path(p) => Some(p.as_ref()), + toolpath::v1::PathOrRef::Ref(_) => None, + }); + let Some(path) = path else { + return ObjectName::new(cache_id, None, None); + }; + + // Earliest step wins: a session is dated when it started, so the + // name doesn't move as the conversation grows. + let date = path + .steps + .iter() + .map(|s| s.step.timestamp.as_str()) + .min() + .and_then(|ts| ts.split('T').next()) + .map(str::to_string); + + ObjectName::new(cache_id, date.as_deref(), topic_of(path).as_deref()) +} + +/// [`name_for`] against the serialized document — the exact bytes about +/// to be uploaded, so the name always describes what actually lands. +/// Degrades to the bare cache id if the body doesn't parse, because a +/// worse name is better than a failed share. +pub(crate) fn name_for_body(body: &str, cache_id: &str) -> ObjectName { + match toolpath::v1::Graph::from_json(body) { + Ok(doc) => name_for(&doc, cache_id), + Err(_) => ObjectName::new(cache_id, None, None), + } +} + +/// The first user prompt, which is what a session is *about*. +/// +/// Falls back to `meta.title`, but only when it looks like a real +/// title: `derive_path` synthesizes `" session: "` when +/// it has nothing better, and repeating the id in the slug would waste +/// the legible half of the name. +fn topic_of(path: &toolpath::v1::Path) -> Option { + for step in &path.steps { + for change in path_changes(step) { + let Some(structural) = &change.structural else { + continue; + }; + if structural.change_type != "conversation.append" { + continue; + } + let role = structural.extra.get("role").and_then(|v| v.as_str()); + if role != Some("user") { + continue; + } + if let Some(text) = structural + .extra + .get("text") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|t| !t.is_empty()) + { + return Some(text.to_string()); + } + } + } + path.meta + .as_ref() + .and_then(|m| m.title.as_deref()) + .filter(|t| !t.contains(" session: ")) + .map(str::to_string) +} + +/// Steps hold their changes in a map; iteration order is arbitrary, so +/// sort by artifact key to keep naming deterministic across runs. +fn path_changes(step: &toolpath::v1::Step) -> Vec<&toolpath::v1::ArtifactChange> { + let mut keys: Vec<&String> = step.change.keys().collect(); + keys.sort(); + keys.into_iter() + .filter_map(|k| step.change.get(k)) + .collect() +} + +/// Parse a user-supplied location into a URL. +/// +/// A value carrying a scheme is taken at its word. A scheme-less value +/// is a **local filesystem path** — `~/traces`, `./out`, `/srv/traces` +/// — expanded, made absolute, and turned into a `file://` URL. Bare +/// strings mean folders rather than buckets because that is what people +/// type when designating a directory; an S3 bucket is named with an +/// explicit `s3://`, which is unambiguous and self-documenting. +/// +/// A *bare relative* path (`my-bucket/traces`) is rejected. It is the +/// one shape that is genuinely ambiguous — overwhelmingly a bucket name +/// typed from memory — and silently resolving it against the current +/// directory would create `./my-bucket/traces` and report success. +/// `./my-bucket/traces` says "yes, relative, I meant it". +fn parse_location(raw: &str) -> Result { + let raw = raw.trim(); + if raw.is_empty() { + bail!("empty location"); + } + + if !raw.contains("://") && is_ambiguously_relative(raw) { + bail!( + "`{raw}` is ambiguous: a scheme-less location is a local path, but this \ + one is relative to the current directory.\n \ + s3://{raw} — if you meant an S3 bucket\n \ + ./{raw} — if you really meant a folder here" + ); + } + + if raw.contains("://") { + let url = Url::parse(raw).with_context(|| format!("`{raw}` is not a valid URL"))?; + if !SCHEMES.contains(&url.scheme()) { + bail!( + "unsupported location scheme `{}://` (expected one of: {})", + url.scheme(), + SCHEMES + .iter() + .map(|s| format!("{s}://")) + .collect::>() + .join(", ") + ); + } + if matches!(url.scheme(), "s3" | "s3a") && url.host_str().unwrap_or_default().is_empty() { + bail!("`{raw}` has no bucket (expected s3://bucket/prefix)"); + } + return Ok(url); + } + + let expanded = expand_tilde(raw); + let absolute = std::path::absolute(&expanded) + .with_context(|| format!("resolve `{}` to an absolute path", expanded.display()))?; + Url::from_directory_path(&absolute).map_err(|()| { + anyhow!( + "`{raw}` is neither a URL nor a usable filesystem path \ + (for an S3 bucket, write it as s3://{raw})" + ) + }) +} + +/// True for a path that is relative *and* doesn't say so explicitly. +/// `./x` and `../x` are deliberate; `x` and `x/y` are the trap. +fn is_ambiguously_relative(raw: &str) -> bool { + !(raw.starts_with('/') + || raw.starts_with('~') + || raw.starts_with("./") + || raw.starts_with("../") + || raw == "." + || raw == ".." + // Windows: `C:\…` and `\\server\share`. + || raw.starts_with('\\') + || raw.as_bytes().get(1) == Some(&b':')) +} + +/// Expand a leading `~/`. Shells normally do this, but a quoted or +/// config-file value arrives literal, and `object_store` would happily +/// create a directory actually named `~`. +fn expand_tilde(raw: &str) -> PathBuf { + let Some(rest) = raw.strip_prefix("~/") else { + return PathBuf::from(raw); + }; + match std::env::var_os("HOME") { + Some(home) => PathBuf::from(home).join(rest), + None => PathBuf::from(raw), + } +} + +/// Render a location for humans: a `file://` URL shows as the plain +/// path it names, which is both shorter and directly pasteable into +/// `path resume`. Everything else shows as its URL. +fn friendly(url: &Url) -> String { + if url.scheme() == "file" + && let Ok(p) = url.to_file_path() + { + return p.to_string_lossy().into_owned(); + } + url.as_str().to_string() +} + +/// Turn an `object_store` error into something a user can act on. Its +/// `NotFound` and `Unauthenticated` variants are the two that matter: +/// the first usually means a typo'd key, the second an unconfigured or +/// stale credential. +fn explain_location(err: object_store::Error, verb: &str, location: &str) -> anyhow::Error { + match err { + object_store::Error::NotFound { .. } => anyhow!("{location} not found"), + object_store::Error::Unauthenticated { .. } + | object_store::Error::PermissionDenied { .. } => { + anyhow!( + "not authorized to {verb} {location}. Run `path auth s3 login` to store \ + credentials, or check the bucket policy for the ones you have." + ) + } + e => anyhow!("failed to {verb} {location}: {}", terse(&e)), + } +} + +/// `object_store` folds "no such bucket" into a generic transport +/// error, so the only handle on it is the message S3 returned. Worth +/// the string match: a typo'd bucket is the single most common way a +/// share target is wrong, and "NoSuchBucket" buried in an XML dump is +/// not an answer. +fn is_missing_container(err: &object_store::Error) -> bool { + let msg = err.to_string(); + msg.contains("NoSuchBucket") || msg.contains("NoSuchHost") || msg.contains("dns error") +} + +/// `object_store` is async; the rest of path-cli is sync. Same tunnel +/// the Pathbase client uses, so both share one runtime. +fn block_on(f: F) -> F::Output { + crate::cmd_pathbase::block_on(f) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_s3_uris() { + let u = ObjectUri::parse("s3://my-bucket/traces/claude-abc.json").unwrap(); + assert_eq!(u.to_string(), "s3://my-bucket/traces/claude-abc.json"); + } + + #[test] + fn container_without_a_key_is_not_an_object() { + let err = ObjectUri::parse("s3://my-bucket").unwrap_err().to_string(); + assert!(err.contains("no object key"), "{err}"); + } + + #[test] + fn looks_like_object_uri_only_matches_known_schemes() { + assert!(looks_like_object_uri("s3://b/k")); + assert!(looks_like_object_uri("s3a://b/k")); + assert!(looks_like_object_uri("file:///tmp/k.json")); + // https belongs to Pathbase; a bare id belongs to the cache. + assert!(!looks_like_object_uri("https://pathbase.dev/a/b/c")); + assert!(!looks_like_object_uri("claude-abc")); + } + + #[test] + fn unsupported_scheme_lists_the_supported_ones() { + let err = ObjectUri::parse("gs://bucket/key.json") + .unwrap_err() + .to_string(); + assert!(err.contains("s3://"), "{err}"); + } + + // ── Destinations ───────────────────────────────────────────────── + + #[test] + fn s3_destination_keys_on_the_cache_id() { + let d = Destination::parse("s3://bkt/pre/fix").unwrap(); + assert!(!d.is_local()); + assert_eq!( + d.uri_for(&ObjectName::bare("claude-abc")).to_string(), + "s3://bkt/pre/fix/claude-abc.json" + ); + } + + #[test] + fn s3_destination_without_a_prefix_writes_at_the_bucket_root() { + let d = Destination::parse("s3://bkt").unwrap(); + assert_eq!( + d.uri_for(&ObjectName::bare("claude-abc")).to_string(), + "s3://bkt/claude-abc.json" + ); + } + + #[test] + fn a_bare_path_is_a_local_folder_not_a_bucket() { + let d = Destination::parse("/srv/traces").unwrap(); + assert!(d.is_local()); + assert_eq!(d.to_string(), "/srv/traces"); + assert_eq!(d.as_url(), "file:///srv/traces/"); + assert_eq!( + d.uri_for(&ObjectName::bare("claude-abc")).to_string(), + "/srv/traces/claude-abc.json" + ); + } + + #[test] + fn an_explicitly_relative_path_is_made_absolute_so_a_stored_default_is_stable() { + let d = Destination::parse("./out").unwrap(); + let expected = std::path::absolute("./out").unwrap(); + assert_eq!(d.to_string(), expected.to_string_lossy()); + } + + #[test] + fn a_bare_relative_path_is_rejected_as_ambiguous() { + // The trap this guards: someone types a bucket name from memory + // and gets ./my-bucket/traces created under their cwd, with the + // share reporting success. + let err = Destination::parse("my-bucket/traces") + .unwrap_err() + .to_string(); + assert!(err.contains("s3://my-bucket/traces"), "{err}"); + assert!(err.contains("./my-bucket/traces"), "{err}"); + + // A single bare word is the same mistake. + assert!(Destination::parse("my-bucket").is_err()); + // Saying "relative, I meant it" is accepted. + assert!(Destination::parse("./my-bucket/traces").is_ok()); + assert!(Destination::parse("../sibling").is_ok()); + } + + #[test] + fn memory_urls_are_rejected() { + // A fresh store per process: anything "shared" there is gone + // before the command exits. + let err = Destination::parse("memory:///x").unwrap_err().to_string(); + assert!(err.contains("unsupported location scheme"), "{err}"); + } + + #[test] + fn a_tilde_path_expands_against_home() { + let home = std::env::var("HOME").unwrap(); + let d = Destination::parse("~/traces").unwrap(); + assert_eq!(d.to_string(), format!("{home}/traces")); + } + + #[test] + fn a_file_url_and_the_equivalent_bare_path_agree() { + let from_path = Destination::parse("/srv/traces").unwrap(); + let from_url = Destination::parse("file:///srv/traces").unwrap(); + assert_eq!( + from_path.uri_for(&ObjectName::bare("x")).to_string(), + from_url.uri_for(&ObjectName::bare("x")).to_string() + ); + } + + #[test] + fn local_destinations_display_as_plain_paths() { + // The printed form is what a user pastes into `path resume`, + // and `path resume /abs/path.json` already works. + let uri = Destination::parse("/srv/traces") + .unwrap() + .uri_for(&ObjectName::bare("claude-abc")); + assert_eq!(uri.to_string(), "/srv/traces/claude-abc.json"); + } + + #[test] + fn cache_id_flattens_the_key() { + let uri = ObjectUri::parse("s3://bkt/traces/claude-abc.json").unwrap(); + assert_eq!(uri.cache_id(), "s3-bkt-traces_claude-abc"); + // s3a is the same store under a different scheme spelling, so + // it must not fork the cache. + let alias = ObjectUri::parse("s3a://bkt/traces/claude-abc.json").unwrap(); + assert_eq!(alias.cache_id(), uri.cache_id()); + } + + // ── S3 settings ────────────────────────────────────────────────── + + #[test] + fn env_fills_only_the_gaps() { + let stored = S3Settings { + region: Some("eu-west-1".to_string()), + ..Default::default() + }; + let merged = merge_env(stored, |k| match k { + "AWS_ACCESS_KEY_ID" => Some("AK".to_string()), + "AWS_SECRET_ACCESS_KEY" => Some("SK".to_string()), + "AWS_REGION" => Some("us-west-2".to_string()), + _ => None, + }); + assert_eq!(merged.access_key_id.as_deref(), Some("AK")); + // Stored wins over env. + assert_eq!(merged.region.as_deref(), Some("eu-west-1")); + } + + #[test] + fn blank_env_values_are_ignored() { + let merged = merge_env(S3Settings::default(), |k| match k { + "AWS_ACCESS_KEY_ID" => Some(" ".to_string()), + _ => None, + }); + assert!(merged.access_key_id.is_none()); + } + + #[test] + fn store_options_carry_credentials_and_endpoint() { + let opts = store_options(&S3Settings { + access_key_id: Some("AK".to_string()), + secret_access_key: Some("SK".to_string()), + endpoint: Some("http://127.0.0.1:9000".to_string()), + ..Default::default() + }); + let get = |k: &str| { + opts.iter() + .find(|(key, _)| *key == k) + .map(|(_, v)| v.as_str()) + }; + assert_eq!(get("aws_access_key_id"), Some("AK")); + assert_eq!(get("aws_secret_access_key"), Some("SK")); + assert_eq!(get("aws_endpoint"), Some("http://127.0.0.1:9000")); + // Plaintext endpoints have to be opted into explicitly. + assert_eq!(get("aws_allow_http"), Some("true")); + assert_eq!(get("aws_region"), Some(DEFAULT_REGION)); + } + + #[test] + fn https_endpoint_does_not_allow_http() { + let opts = store_options(&S3Settings { + endpoint: Some("https://minio.example".to_string()), + ..Default::default() + }); + assert!(!opts.iter().any(|(k, _)| *k == "aws_allow_http")); + } + + #[test] + fn stored_settings_round_trip_through_disk() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("s3.json"); + let cfg = S3Settings { + region: Some("us-east-2".to_string()), + access_key_id: Some("AK".to_string()), + secret_access_key: Some("SK".to_string()), + ..Default::default() + }; + store(&path, &cfg).unwrap(); + assert_eq!(load_stored(&path).unwrap().unwrap(), cfg); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&path).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + + clear(&path).unwrap(); + assert!(load_stored(&path).unwrap().is_none()); + // Clearing settings that aren't there is not an error. + clear(&path).unwrap(); + } + + // ── Round trips against a local folder ─────────────────────────── + + #[test] + fn put_then_get_round_trips() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + let cfg = S3Settings::default(); + let uri = dest.uri_for(&ObjectName::bare("claude-abc")); + let body = br#"{"graph":{"id":"g"},"paths":[]}"#; + + uri.put(&cfg, body).unwrap(); + assert_eq!(uri.get(&cfg).unwrap(), String::from_utf8_lossy(body)); + assert!(dir.path().join("claude-abc.json").is_file()); + } + + #[test] + fn put_overwrites_an_existing_object() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + let cfg = S3Settings::default(); + let uri = dest.uri_for(&ObjectName::bare("claude-abc")); + + uri.put(&cfg, b"{\"v\":1}").unwrap(); + uri.put(&cfg, b"{\"v\":2}").unwrap(); + assert_eq!(uri.get(&cfg).unwrap(), "{\"v\":2}"); + } + + #[test] + fn nested_prefixes_are_created_on_write() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&format!("{}/a/b/c", dir.path().display())).unwrap(); + dest.uri_for(&ObjectName::bare("claude-abc")) + .put(&S3Settings::default(), b"{}") + .unwrap(); + assert!(dir.path().join("a/b/c/claude-abc.json").is_file()); + } + + #[test] + fn missing_object_says_not_found() { + let dir = tempfile::tempdir().unwrap(); + let uri = Destination::parse(&dir.path().to_string_lossy()) + .unwrap() + .uri_for(&ObjectName::bare("nope")); + let err = uri.get(&S3Settings::default()).unwrap_err().to_string(); + assert!(err.contains("not found"), "{err}"); + assert!(err.contains("nope.json"), "{err}"); + } + + // ── Naming ─────────────────────────────────────────────────────── + + /// A one-step agent document whose first user turn is `prompt`. + fn doc_with(prompt: &str, timestamp: &str) -> toolpath::v1::Graph { + let body = serde_json::json!({ + "graph": { "id": "g1" }, + "paths": [{ + "path": { "id": "p1", "head": "s1" }, + "steps": [{ + "step": { + "id": "s1", "parents": [], + "actor": "agent:claude-code", + "timestamp": timestamp + }, + "change": { "claude-code://s": { "structural": { + "type": "conversation.append", + "role": "user", + "text": prompt + }}} + }] + }] + }); + toolpath::v1::Graph::from_json(&body.to_string()).unwrap() + } + + #[test] + fn a_name_leads_with_the_date_and_topic() { + let doc = doc_with("Add S3 support to share", "2026-08-07T09:15:00Z"); + assert_eq!( + name_for(&doc, "claude-abc123").to_string(), + "2026-08-07-add-s3-support-to-share-claude-abc123" + ); + } + + #[test] + fn a_name_is_stable_as_the_session_grows() { + // The date comes from the *earliest* step, so appending turns + // can't move the object and leave a duplicate behind. + let short = doc_with("Fix the parser", "2026-08-07T09:15:00Z"); + let name = name_for(&short, "claude-abc"); + + let mut grown = short.clone(); + if let toolpath::v1::PathOrRef::Path(p) = &mut grown.paths[0] { + let mut later = p.steps[0].clone(); + later.step.id = "s2".to_string(); + later.step.timestamp = "2026-08-09T18:00:00Z".to_string(); + p.steps.push(later); + } + assert_eq!(name_for(&grown, "claude-abc"), name); + } + + #[test] + fn a_long_prompt_is_truncated_on_a_word_boundary() { + let doc = doc_with( + "Add support to share and resume to and from S3 and a way to configure credentials", + "2026-08-07T00:00:00Z", + ); + let name = name_for(&doc, "claude-abc").to_string(); + assert!( + name.starts_with("2026-08-07-add-support-to-share"), + "{name}" + ); + assert!(name.ends_with("-claude-abc"), "{name}"); + assert!(!name.contains("--"), "no empty slug segments: {name}"); + } + + #[test] + fn a_prompt_of_pure_punctuation_degrades_to_date_and_id() { + let doc = doc_with("!!! ???", "2026-08-07T00:00:00Z"); + assert_eq!( + name_for(&doc, "claude-abc").to_string(), + "2026-08-07-claude-abc" + ); + } + + #[test] + fn a_synthesized_title_is_not_worth_slugging() { + // `derive_path` writes "claude-code session: abc" when it has + // nothing better; repeating the id would waste the legible half + // of the name. + let body = serde_json::json!({ + "graph": { "id": "g1" }, + "paths": [{ + "path": { "id": "p1", "head": "s1" }, + "meta": { "title": "claude-code session: abc123" }, + "steps": [{ + "step": { "id": "s1", "parents": [], "actor": "agent:claude-code", + "timestamp": "2026-08-07T00:00:00Z" }, + "change": { "f": { "structural": { "type": "file.edit" } } } + }] + }] + }); + let doc = toolpath::v1::Graph::from_json(&body.to_string()).unwrap(); + assert_eq!( + name_for(&doc, "claude-abc").to_string(), + "2026-08-07-claude-abc" + ); + } + + #[test] + fn an_unparseable_body_still_gets_a_name() { + // A worse name beats a failed share. + assert_eq!( + name_for_body("not json", "claude-abc").to_string(), + "claude-abc" + ); + } + + // ── Listing ────────────────────────────────────────────────────── + + #[test] + fn listing_returns_shared_documents_newest_first() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + let cfg = S3Settings::default(); + + for name in ["2026-08-01-older-claude-a", "2026-08-09-newer-claude-b"] { + dest.uri_for(&ObjectName::bare(name)) + .put(&cfg, b"{}") + .unwrap(); + // Distinct mtimes; the local backend stamps on write. + std::thread::sleep(std::time::Duration::from_millis(10)); + } + // Noise that isn't a shared document. + std::fs::write(dir.path().join("README.txt"), "hi").unwrap(); + + let entries = dest.list(&cfg).unwrap(); + assert_eq!(entries.len(), 2, "{entries:?}"); + assert_eq!(entries[0].stem, "2026-08-09-newer-claude-b"); + assert_eq!(entries[1].stem, "2026-08-01-older-claude-a"); + assert!(entries[0].uri.to_string().ends_with(".json")); + assert!(entries[0].size > 0); + } + + #[test] + fn listing_does_not_recurse_into_sub_prefixes() { + // A destination is a place you share *to*; its immediate + // contents are what a picker should offer. + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + let cfg = S3Settings::default(); + dest.uri_for(&ObjectName::bare("here")) + .put(&cfg, b"{}") + .unwrap(); + + let nested = Destination::parse(&format!("{}/deeper", dir.path().display())).unwrap(); + nested + .uri_for(&ObjectName::bare("there")) + .put(&cfg, b"{}") + .unwrap(); + + let entries = dest.list(&cfg).unwrap(); + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].stem, "here"); + } + + #[test] + fn listing_an_empty_destination_is_not_an_error() { + let dir = tempfile::tempdir().unwrap(); + let dest = Destination::parse(&dir.path().to_string_lossy()).unwrap(); + assert!(dest.list(&S3Settings::default()).unwrap().is_empty()); + } + + #[test] + fn probing_a_local_destination_is_a_no_op() { + let dir = tempfile::tempdir().unwrap(); + // Not created yet: a local destination makes itself on write, + // so there's nothing to preflight. + let dest = Destination::parse(&format!("{}/not/yet", dir.path().display())).unwrap(); + dest.probe(&S3Settings::default()).unwrap(); + } +} diff --git a/crates/path-cli/src/target.rs b/crates/path-cli/src/target.rs new file mode 100644 index 00000000..cbbd9ea7 --- /dev/null +++ b/crates/path-cli/src/target.rs @@ -0,0 +1,497 @@ +//! Where `path share` uploads to, and how that gets configured once. +//! +//! A share target is either Pathbase — the hosted service, with its own +//! auth, repos, and visibility — or an object-storage +//! [`Destination`](crate::store::Destination): an S3 bucket, an +//! S3-compatible endpoint, or a plain local folder. +//! +//! The default lives in **one** place, `~/.toolpath/config.json`, and +//! not inside either credential file. "Where does my next share go?" +//! must have a single answer; if each backend's own config could claim +//! to be the default, that answer becomes a precedence puzzle the day a +//! third backend appears. +//! +//! Resolution order, highest first: +//! +//! 1. `--to ` on the command +//! 2. `$TOOLPATH_SHARE_TARGET` +//! 3. `default_target` in `config.json` (`path target `) +//! 4. Pathbase +//! +//! Nothing is inferred from which credentials happen to exist. A share +//! that silently changes destination is a data-egress bug, not a +//! convenience — so the default only ever moves because someone moved +//! it. The one concession is a guard at the bottom of the order: see +//! [`resolve`]. + +use anyhow::{Context, Result, bail}; +use serde::{Deserialize, Serialize}; +use std::path::{Path, PathBuf}; + +use crate::config::config_dir; +use crate::store::Destination; + +pub(crate) const CONFIG_FILE: &str = "config.json"; +pub(crate) const TARGET_ENV: &str = "TOOLPATH_SHARE_TARGET"; + +/// The literal that selects Pathbase, for `--to` and for the stored +/// default. Everything else is parsed as an object-storage location. +pub(crate) const PATHBASE: &str = "pathbase"; + +/// General CLI settings at `~/.toolpath/config.json`. +/// +/// Not credentials — this file is preferences, and is the one place a +/// future non-credential setting should land rather than growing a +/// fourth file. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct Settings { + /// `pathbase`, or an object-storage URL. Stored canonicalized (a + /// folder is written as its `file://` URL) so re-reading it from a + /// different working directory can't change where it points. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub default_target: Option, +} + +pub(crate) fn settings_path() -> Result { + Ok(config_dir()?.join(CONFIG_FILE)) +} + +pub(crate) fn load_settings(path: &Path) -> Result { + Ok(crate::config::read_private_json(path)?.unwrap_or_default()) +} + +pub(crate) fn store_settings(path: &Path, s: &Settings) -> Result<()> { + crate::config::write_private_json(path, s) +} + +/// Where a share goes. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum Target { + Pathbase, + Object(Destination), +} + +impl std::fmt::Display for Target { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Target::Pathbase => f.write_str(PATHBASE), + Target::Object(d) => write!(f, "{d}"), + } + } +} + +impl Target { + /// Parse a `--to` value or a stored default. + pub(crate) fn parse(raw: &str) -> Result { + let raw = raw.trim(); + if raw.eq_ignore_ascii_case(PATHBASE) { + return Ok(Target::Pathbase); + } + Ok(Target::Object(Destination::parse(raw)?)) + } + + /// The canonical string to persist. Object destinations round-trip + /// as URLs so a bare relative path can't be stored. + pub(crate) fn as_stored(&self) -> String { + match self { + Target::Pathbase => PATHBASE.to_string(), + Target::Object(d) => d.as_url().to_string(), + } + } +} + +/// Where a resolved target came from, so `path target` and the +/// share confirmation can say *why* this is the destination. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Origin { + Flag, + Env, + Config, + Builtin, +} + +impl Origin { + pub(crate) fn describe(self) -> &'static str { + match self { + Origin::Flag => "--to", + Origin::Env => TARGET_ENV, + Origin::Config => "configured default", + Origin::Builtin => "built-in default", + } + } +} + +/// The explicitly-chosen target, if there is one: flag, then env, then +/// the stored default. `None` means nothing has been configured and the +/// caller decides what the absence means. +fn lookup(flag: Option<&str>) -> Result> { + if let Some(raw) = flag.map(str::trim).filter(|s| !s.is_empty()) { + return Ok(Some(( + Target::parse(raw).with_context(|| format!("--to {raw}"))?, + Origin::Flag, + ))); + } + if let Some(raw) = std::env::var(TARGET_ENV) + .ok() + .filter(|v| !v.trim().is_empty()) + { + return Ok(Some(( + Target::parse(&raw).with_context(|| format!("${TARGET_ENV}"))?, + Origin::Env, + ))); + } + let settings_path = settings_path()?; + if let Some(raw) = load_settings(&settings_path)?.default_target { + return Ok(Some(( + Target::parse(&raw) + .with_context(|| format!("default_target in {}", settings_path.display()))?, + Origin::Config, + ))); + } + Ok(None) +} + +/// True when falling through to Pathbase would be a bad guess: S3 +/// credentials exist, no Pathbase session does, and nothing has been +/// designated. Uploading anyway would hit the *anonymous public* +/// endpoint — a surprising place for someone who has only ever +/// configured S3. +fn fallback_is_a_bad_guess() -> Result { + Ok(s3_configured()? && !pathbase_logged_in()?) +} + +/// Resolve the share target for an upload. Errors rather than guessing +/// when the fallback would be surprising — see +/// [`fallback_is_a_bad_guess`]. +pub(crate) fn resolve(flag: Option<&str>) -> Result<(Target, Origin)> { + if let Some(hit) = lookup(flag)? { + return Ok(hit); + } + if fallback_is_a_bad_guess()? { + bail!( + "No share target configured. S3 credentials are stored but there's no \ + default target and no Pathbase login, and defaulting to Pathbase here \ + would publish anonymously.\n\ + \n \ + path target s3://my-bucket/traces # or a folder: path target ~/traces\n \ + path share --to pathbase # to publish to Pathbase this once" + ); + } + Ok((Target::Pathbase, Origin::Builtin)) +} + +/// What [`resolve`] would do, as a line of prose that never fails. +/// +/// Status commands report the situation; they must not inherit an +/// upload-time refusal, or `path auth s3 status` would break precisely +/// when the user most needs it to explain itself. +pub(crate) fn describe_effective() -> Result { + if let Some((target, origin)) = lookup(None)? { + return Ok(format!("{target} ({})", origin.describe())); + } + if fallback_is_a_bad_guess()? { + return Ok( + "not configured — S3 credentials are stored but no default target is set \ + (run `path target`)" + .to_string(), + ); + } + Ok(format!( + "{} ({})", + Target::Pathbase, + Origin::Builtin.describe() + )) +} + +/// The stored default, if any, plus where it is stored. +pub(crate) fn default_target() -> Result<(Option, PathBuf)> { + let path = settings_path()?; + let stored = load_settings(&path)?.default_target; + let parsed = stored.as_deref().map(Target::parse).transpose()?; + Ok((parsed, path)) +} + +pub(crate) fn set_default(target: &Target) -> Result { + let path = settings_path()?; + let mut settings = load_settings(&path)?; + settings.default_target = Some(target.as_stored()); + store_settings(&path, &settings)?; + Ok(path) +} + +pub(crate) fn clear_default() -> Result { + let path = settings_path()?; + let mut settings = load_settings(&path)?; + settings.default_target = None; + store_settings(&path, &settings)?; + Ok(path) +} + +fn s3_configured() -> Result { + Ok(crate::store::load_stored(&crate::store::config_path()?)?.is_some()) +} + +fn pathbase_logged_in() -> Result { + Ok(crate::cmd_pathbase::load_session(&crate::cmd_pathbase::credentials_path()?)?.is_some()) +} + +/// Reject the Pathbase-only flags when the target isn't Pathbase, and +/// treat their presence as selecting Pathbase when no `--to` was given. +/// +/// `--repo` / `--public` / `--anon` / `--url` / `--name` are meaningless +/// for object storage, so using one is a clear statement of intent — +/// clear enough to override a configured S3 default, and clear enough +/// that combining it with an explicit `--to s3://…` is a mistake worth +/// naming rather than silently resolving. +pub(crate) fn apply_pathbase_flags( + resolved: (Target, Origin), + pathbase_flags: bool, +) -> Result<(Target, Origin)> { + let (target, origin) = resolved; + if !pathbase_flags { + return Ok((target, origin)); + } + match (&target, origin) { + (Target::Pathbase, _) => Ok((target, origin)), + (Target::Object(_), Origin::Flag) => bail!( + "--repo / --public / --anon / --url / --name are Pathbase options, \ + but --to names object storage. Drop the Pathbase flags, or use \ + `--to pathbase`." + ), + // The default said object storage, but this invocation asked + // for something only Pathbase can do. Honor the request. + (Target::Object(_), _) => Ok((Target::Pathbase, Origin::Flag)), + } +} + +/// Prove a target works, at the moment someone chooses it. +/// +/// Configuration time is the right time to fail: a target is set once +/// and used many times, so a wrong one discovered at share time has +/// already cost a session pick and a derivation — and is discovered +/// when the user wanted a result, not a setup step. +/// +/// Deliberately a real write (see [`Destination::verify`]), including +/// when no credentials are stored: that is the case *most* likely to +/// break later, and `object_store`'s credential chain may still supply +/// one from an instance role, so guessing helps nobody. Local folders +/// go through the same path — it catches an unwritable parent or a +/// read-only mount, and creates the folder as a side effect. +pub(crate) fn verify(target: &Target, settings: &crate::store::S3Settings) -> Result<()> { + match target { + // Pathbase has its own auth story, reported by `path auth status`. + Target::Pathbase => Ok(()), + Target::Object(dest) => dest.verify(settings), + } +} + +/// Cheap reachability check for the moment before an upload. +/// +/// Weaker than [`verify`] on purpose: the upload is about to happen and +/// will report its own failure, so all this needs to buy is not wasting +/// a derivation on a typo'd bucket. Local folders create themselves on +/// write and need nothing. +pub(crate) fn check_reachable(target: &Target, settings: &crate::store::S3Settings) -> Result<()> { + let Target::Object(dest) = target else { + return Ok(()); + }; + if dest.is_local() { + return Ok(()); + } + if let Some(r) = settings.resolved_credentials() + && r.credentials.is_none() + { + // Nothing local. object_store will still try instance metadata, + // ECS, and web identity, which is exactly right on a server — + // so this is a note, not a failure. + eprintln!("note: no local S3 credentials found ({}).", r.source); + } + dest.probe(settings) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::{CONFIG_DIR_ENV, TEST_ENV_LOCK}; + + /// Pin `$TOOLPATH_CONFIG_DIR` at an empty tempdir and clear + /// `$TOOLPATH_SHARE_TARGET`, so resolution can't see the + /// developer's real configuration. + fn with_sandbox(f: impl FnOnce(&Path) -> R) -> R { + let temp = tempfile::tempdir().unwrap(); + let _g = TEST_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner()); + let prev_dir = std::env::var_os(CONFIG_DIR_ENV); + let prev_target = std::env::var_os(TARGET_ENV); + unsafe { + std::env::set_var(CONFIG_DIR_ENV, temp.path()); + std::env::remove_var(TARGET_ENV); + } + let out = f(temp.path()); + unsafe { + match prev_dir { + Some(v) => std::env::set_var(CONFIG_DIR_ENV, v), + None => std::env::remove_var(CONFIG_DIR_ENV), + } + match prev_target { + Some(v) => std::env::set_var(TARGET_ENV, v), + None => std::env::remove_var(TARGET_ENV), + } + } + out + } + + #[test] + fn pathbase_is_spelled_by_name() { + assert_eq!(Target::parse("pathbase").unwrap(), Target::Pathbase); + assert_eq!(Target::parse("PathBase").unwrap(), Target::Pathbase); + assert_eq!(Target::parse("pathbase").unwrap().as_stored(), "pathbase"); + } + + #[test] + fn a_bucket_url_is_an_object_target() { + let t = Target::parse("s3://bkt/traces").unwrap(); + assert_eq!(t.to_string(), "s3://bkt/traces"); + assert_eq!(t.as_stored(), "s3://bkt/traces"); + } + + #[test] + fn a_folder_is_an_object_target_stored_as_a_url() { + let t = Target::parse("/srv/traces").unwrap(); + // Displayed as a path… + assert_eq!(t.to_string(), "/srv/traces"); + // …but persisted as a URL, so it can't drift with cwd. + assert_eq!(t.as_stored(), "file:///srv/traces/"); + } + + #[test] + fn nothing_configured_resolves_to_pathbase() { + with_sandbox(|_| { + let (target, origin) = resolve(None).unwrap(); + assert_eq!(target, Target::Pathbase); + assert_eq!(origin, Origin::Builtin); + }); + } + + #[test] + fn the_flag_beats_everything() { + with_sandbox(|_| { + set_default(&Target::parse("s3://configured/x").unwrap()).unwrap(); + let (target, origin) = resolve(Some("s3://flagged/y")).unwrap(); + assert_eq!(target.to_string(), "s3://flagged/y"); + assert_eq!(origin, Origin::Flag); + }); + } + + #[test] + fn the_env_beats_the_stored_default() { + with_sandbox(|_| { + set_default(&Target::parse("s3://configured/x").unwrap()).unwrap(); + unsafe { std::env::set_var(TARGET_ENV, "s3://from-env/z") }; + let (target, origin) = resolve(None).unwrap(); + unsafe { std::env::remove_var(TARGET_ENV) }; + assert_eq!(target.to_string(), "s3://from-env/z"); + assert_eq!(origin, Origin::Env); + }); + } + + #[test] + fn the_stored_default_is_used_when_no_flag_or_env() { + with_sandbox(|_| { + set_default(&Target::parse("s3://configured/x").unwrap()).unwrap(); + let (target, origin) = resolve(None).unwrap(); + assert_eq!(target.to_string(), "s3://configured/x"); + assert_eq!(origin, Origin::Config); + }); + } + + #[test] + fn a_stored_folder_default_survives_a_round_trip() { + with_sandbox(|_| { + set_default(&Target::parse("/srv/traces").unwrap()).unwrap(); + let (target, _) = resolve(None).unwrap(); + assert_eq!(target.to_string(), "/srv/traces"); + }); + } + + #[test] + fn clearing_the_default_falls_back_to_pathbase() { + with_sandbox(|_| { + set_default(&Target::parse("s3://configured/x").unwrap()).unwrap(); + clear_default().unwrap(); + assert_eq!(resolve(None).unwrap().1, Origin::Builtin); + }); + } + + #[test] + fn s3_credentials_without_a_default_refuse_to_publish_anonymously() { + with_sandbox(|dir| { + crate::store::store( + &dir.join(crate::store::S3_CONFIG_FILE), + &crate::store::S3Settings { + access_key_id: Some("AK".to_string()), + ..Default::default() + }, + ) + .unwrap(); + let err = match resolve(None) { + Err(e) => e.to_string(), + Ok(t) => panic!("expected a refusal, got {t:?}"), + }; + assert!(err.contains("path target"), "{err}"); + }); + } + + #[test] + fn an_invalid_stored_default_names_the_file() { + with_sandbox(|dir| { + store_settings( + &dir.join(CONFIG_FILE), + &Settings { + default_target: Some("gs://nope/x".to_string()), + }, + ) + .unwrap(); + let err = match resolve(None) { + Err(e) => format!("{e:#}"), + Ok(t) => panic!("expected a parse failure, got {t:?}"), + }; + assert!(err.contains("default_target"), "{err}"); + assert!(err.contains(CONFIG_FILE), "{err}"); + }); + } + + // ── Pathbase-flag interaction ──────────────────────────────────── + + #[test] + fn pathbase_flags_override_an_object_default() { + let resolved = (Target::parse("s3://bkt/x").unwrap(), Origin::Config); + let (target, origin) = apply_pathbase_flags(resolved, true).unwrap(); + assert_eq!(target, Target::Pathbase); + assert_eq!(origin, Origin::Flag); + } + + #[test] + fn pathbase_flags_with_an_explicit_object_to_is_an_error() { + let resolved = (Target::parse("s3://bkt/x").unwrap(), Origin::Flag); + let err = match apply_pathbase_flags(resolved, true) { + Err(e) => e.to_string(), + Ok(t) => panic!("expected a conflict, got {t:?}"), + }; + assert!(err.contains("--to pathbase"), "{err}"); + } + + #[test] + fn pathbase_flags_are_a_no_op_when_the_target_is_already_pathbase() { + let resolved = (Target::Pathbase, Origin::Builtin); + assert_eq!( + apply_pathbase_flags(resolved.clone(), true).unwrap(), + resolved + ); + } + + #[test] + fn a_local_folder_needs_no_credentials() { + let target = Target::parse("/srv/traces").unwrap(); + check_reachable(&target, &crate::store::S3Settings::default()).unwrap(); + } +} diff --git a/crates/path-cli/tests/resume.rs b/crates/path-cli/tests/resume.rs index f751c40e..b160cefe 100644 --- a/crates/path-cli/tests/resume.rs +++ b/crates/path-cli/tests/resume.rs @@ -275,6 +275,252 @@ fn cache_id_input_loads_and_projects() { assert_eq!(cap.args[0], "-r"); } +// ── Object-storage input ──────────────────────────────────────────── +// +// `file://` exercises the same `object_store` path an `s3://` bucket +// takes, so these cover the S3 resume flow without a network. + +/// Write `graph` into `bucket` as `.json` and return its URL. +fn seed_object(bucket: &std::path::Path, name: &str, graph: &toolpath::v1::Graph) -> String { + std::fs::write( + bucket.join(format!("{name}.json")), + graph.to_json().unwrap(), + ) + .unwrap(); + format!("file://{}/{name}.json", bucket.display()) +} + +#[test] +fn object_store_input_downloads_projects_and_caches() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-s3-int", + )); + let uri = seed_object(bucket.path(), "claude-abc", &graph); + + let recorder = RecordingExec::default(); + run_with_strategy( + ResumeArgs { + input: uri, + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &recorder, + ) + .unwrap(); + + let cap = recorder.captured(); + assert_eq!(cap.binary, "claude"); + assert_eq!(cap.args[0], "-r"); + + // The download is cached, so a second resume costs no fetch. + let documents = std::path::PathBuf::from(std::env::var_os("TOOLPATH_CONFIG_DIR").unwrap()) + .join("documents"); + let cached: Vec = std::fs::read_dir(&documents) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(cached.len(), 1, "expected one cached doc, got {cached:?}"); + assert!( + cached[0].ends_with("claude-abc.json"), + "unexpected cache id: {cached:?}" + ); +} + +#[test] +fn object_store_input_with_no_cache_leaves_the_cache_empty() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-s3-nocache", + )); + let uri = seed_object(bucket.path(), "claude-nocache", &graph); + + let recorder = RecordingExec::default(); + run_with_strategy( + ResumeArgs { + input: uri, + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: true, + force: false, + url: None, + }, + &recorder, + ) + .unwrap(); + + assert_eq!(recorder.captured().binary, "claude"); + let documents = std::path::PathBuf::from(std::env::var_os("TOOLPATH_CONFIG_DIR").unwrap()) + .join("documents"); + assert!( + !documents.exists() || std::fs::read_dir(&documents).unwrap().next().is_none(), + "--no-cache must not write the cache" + ); +} + +#[test] +fn object_store_input_prefers_the_cache_over_a_refetch() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-s3-cached", + )); + let uri = seed_object(bucket.path(), "claude-cached", &graph); + + let args = || ResumeArgs { + input: uri.clone(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }; + run_with_strategy(args(), &RecordingExec::default()).unwrap(); + + // Delete the object: a cache hit must not need it any more. + std::fs::remove_file(bucket.path().join("claude-cached.json")).unwrap(); + let recorder = RecordingExec::default(); + run_with_strategy(args(), &recorder).unwrap(); + assert_eq!(recorder.captured().binary, "claude"); +} + +#[test] +fn missing_object_reports_not_found() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let err = run_with_strategy( + ResumeArgs { + input: format!("file://{}/absent.json", bucket.path().display()), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &RecordingExec::default(), + ) + .unwrap_err(); + assert!(err.to_string().contains("not found"), "actual: {err}"); +} + +// ── Resuming from a destination rather than a document ────────────── +// +// `path share` has a picker across every harness; resuming from where +// you shared to needs one too, or a destination is a write-only hole. + +#[test] +fn a_destination_holding_one_document_resumes_it_without_asking() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-only-one", + )); + seed_object(bucket.path(), "2026-08-07-only-one-claude-abc", &graph); + + let recorder = RecordingExec::default(); + run_with_strategy( + ResumeArgs { + // The destination, not a document inside it. + input: bucket.path().to_string_lossy().into_owned(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &recorder, + ) + .unwrap(); + + assert_eq!(recorder.captured().binary, "claude"); +} + +#[test] +fn an_empty_destination_says_how_to_fill_it() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let err = run_with_strategy( + ResumeArgs { + input: bucket.path().to_string_lossy().into_owned(), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &RecordingExec::default(), + ) + .unwrap_err(); + let s = err.to_string(); + assert!(s.contains("no shared documents"), "actual: {s}"); + assert!(s.contains("path share --to"), "actual: {s}"); +} + +#[test] +fn a_destination_url_without_a_json_suffix_is_browsed_not_fetched() { + let _env = env_lock(); + let _home = ScopedHome::new(); + let _path = ScopedPath::with_binary("claude"); + let cwd = tempfile::tempdir().unwrap(); + let bucket = tempfile::tempdir().unwrap(); + + let graph = toolpath::v1::Graph::from_path(make_convo_path( + "agent:claude-code", + "claude-code://resume-url-browse", + )); + seed_object(bucket.path(), "2026-08-07-browse-claude-abc", &graph); + + let recorder = RecordingExec::default(); + run_with_strategy( + ResumeArgs { + // A `file://` URL naming the container, not an object. + input: format!("file://{}", bucket.path().display()), + cwd: Some(cwd.path().to_path_buf()), + harness: Some(Harness::Claude), + no_cache: false, + force: false, + url: None, + }, + &recorder, + ) + .unwrap(); + + assert_eq!(recorder.captured().binary, "claude"); +} + // ── Rejection cases ───────────────────────────────────────────────── #[test] diff --git a/crates/path-cli/tests/share_targets.rs b/crates/path-cli/tests/share_targets.rs new file mode 100644 index 00000000..aba386c8 --- /dev/null +++ b/crates/path-cli/tests/share_targets.rs @@ -0,0 +1,605 @@ +//! Integration tests for share targets: the configured default, and +//! object storage (S3 or a folder). +//! +//! Every test runs against a local folder — the same `object_store` +//! code path an `s3://` bucket takes, minus the network. That's the +//! point of making folders first-class: the plumbing under test +//! (target resolution, destination parsing, key layout, upload, +//! download, cache landing) is backend-independent, so exercising it +//! locally covers the S3 case without credentials or a mock endpoint. + +#![cfg(not(target_os = "emscripten"))] + +use assert_cmd::Command; +use predicates::prelude::*; +use std::path::Path; + +fn cmd(config_dir: &Path) -> Command { + let mut c = Command::cargo_bin("path").unwrap(); + c.env("TOOLPATH_CONFIG_DIR", config_dir); + // Keep an ambient developer AWS profile or share target out of the + // test's way. + for k in [ + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "AWS_REGION", + "AWS_DEFAULT_REGION", + "AWS_ENDPOINT_URL", + "AWS_ENDPOINT_URL_S3", + "AWS_PROFILE", + "TOOLPATH_SHARE_TARGET", + ] { + c.env_remove(k); + } + // Credential resolution reads `~/.aws` now, so point it at files + // that don't exist. Without this a developer's real default profile + // leaks in and the "no credentials" tests make real network calls. + c.env( + "AWS_SHARED_CREDENTIALS_FILE", + "/nonexistent/toolpath-test/credentials", + ); + c.env("AWS_CONFIG_FILE", "/nonexistent/toolpath-test/config"); + c +} + +/// A minimal single-step agent document, written to `dir/doc.json`. +fn write_doc(dir: &Path) -> std::path::PathBuf { + let body = serde_json::json!({ + "graph": { "id": "g1" }, + "paths": [{ + "path": { "id": "p1", "head": "s1" }, + "steps": [{ + "step": { + "id": "s1", + "parents": [], + "actor": "agent:claude-code", + "timestamp": "2026-01-01T00:00:00Z" + }, + "change": { + "claude-code://share-target-int": { + "structural": { + "type": "conversation.append", + "role": "user", + "text": "hello" + } + } + } + }] + }] + }); + let p = dir.join("doc.json"); + std::fs::write(&p, serde_json::to_string(&body).unwrap()).unwrap(); + p +} + +// ── path target ─────────────────────────────────────────────── + +#[test] +fn a_folder_can_be_designated_as_the_share_target() { + let config = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let folder_str = folder.path().to_string_lossy().into_owned(); + + cmd(config.path()) + .args(["target", &folder_str]) + .assert() + .success() + .stdout(predicate::str::contains(format!( + "Share target set to {folder_str}" + ))) + // A folder needs no credentials, so nothing should nag about them. + .stdout(predicate::str::contains("credentials").not()); + + // Stored as a URL so it can't be re-read relative to another cwd… + let raw = std::fs::read_to_string(config.path().join("config.json")).unwrap(); + assert!(raw.contains("file://"), "{raw}"); + // …but reported back as the path the user typed. + cmd(config.path()) + .args(["target"]) + .assert() + .success() + .stdout(predicate::str::contains(&folder_str)) + .stdout(predicate::str::contains("configured default")); +} + +/// Point the CLI at an endpoint nothing is listening on, so +/// verification fails deterministically and offline. +fn unreachable_s3(config: &Path) { + cmd(config) + .args(["auth", "s3", "login"]) + .args(["--endpoint", "http://127.0.0.1:1"]) + .args(["--access-key-id", "AK", "--secret-access-key", "SK"]) + .assert() + .success(); +} + +#[test] +fn a_bucket_that_cant_be_written_to_is_refused_at_configuration_time() { + // The whole point of checking here: a target is set once and used + // many times, so a wrong one must not survive to cost a session + // pick and a derivation later. + let config = tempfile::tempdir().unwrap(); + unreachable_s3(config.path()); + + cmd(config.path()) + .args(["target", "s3://my-bucket/traces"]) + .assert() + .failure() + .stderr(predicate::str::contains( + "can't write to s3://my-bucket/traces", + )) + .stderr(predicate::str::contains("--no-verify")) + // object_store's retry epilogue is not an answer to anything. + .stderr(predicate::str::contains("max_retries").not()); + + assert!( + !config.path().join("config.json").exists(), + "a target that failed verification must not be stored" + ); +} + +#[test] +fn no_verify_stores_an_unchecked_target() { + let config = tempfile::tempdir().unwrap(); + unreachable_s3(config.path()); + + cmd(config.path()) + .args(["target", "s3://my-bucket/traces", "--no-verify"]) + .assert() + .success() + .stdout(predicate::str::contains("(not verified)")); + + let raw = std::fs::read_to_string(config.path().join("config.json")).unwrap(); + assert!(raw.contains("s3://my-bucket/traces"), "{raw}"); +} + +#[test] +fn designating_a_folder_creates_it_and_leaves_no_probe_behind() { + // Verification is a real write, so it proves the folder is usable + // and makes it exist — and cleans up after itself. + let config = tempfile::tempdir().unwrap(); + let parent = tempfile::tempdir().unwrap(); + let folder = parent.path().join("brand/new/traces"); + + cmd(config.path()) + .args(["target", &folder.to_string_lossy()]) + .assert() + .success(); + + assert!(folder.is_dir(), "the folder should exist after designation"); + let leftovers: Vec = std::fs::read_dir(&folder) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert!(leftovers.is_empty(), "probe not cleaned up: {leftovers:?}"); +} + +#[test] +fn an_unwritable_folder_is_refused_at_configuration_time() { + let config = tempfile::tempdir().unwrap(); + // A file, not a directory: nothing can be written underneath it. + let blocker = tempfile::tempdir().unwrap(); + let occupied = blocker.path().join("a-file"); + std::fs::write(&occupied, "not a directory").unwrap(); + + cmd(config.path()) + .args(["target", &occupied.join("traces").to_string_lossy()]) + .assert() + .failure() + .stderr(predicate::str::contains("can't write to")); +} + +#[test] +fn target_with_no_argument_explains_the_options() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["target"]) + .assert() + .success() + .stdout(predicate::str::contains("No share target")) + .stdout(predicate::str::contains("In effect now: pathbase")) + .stdout(predicate::str::contains("path target ~/")); +} + +#[test] +fn target_clear_falls_back_to_pathbase() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["target", "s3://my-bucket/traces", "--no-verify"]) + .assert() + .success(); + cmd(config.path()) + .args(["target", "--clear"]) + .assert() + .success() + .stdout(predicate::str::contains("uploads to Pathbase")); + cmd(config.path()) + .args(["target"]) + .assert() + .success() + .stdout(predicate::str::contains("In effect now: pathbase")); +} + +#[test] +fn the_env_var_overrides_the_stored_default() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["target", "s3://stored/x", "--no-verify"]) + .assert() + .success(); + cmd(config.path()) + .args(["target"]) + .env("TOOLPATH_SHARE_TARGET", "s3://from-env/y") + .assert() + .success() + // The stored value is still reported as stored… + .stdout(predicate::str::contains("s3://stored/x")) + // …but the env var is what would actually be used. + .stdout(predicate::str::contains( + "In effect now: s3://from-env/y (TOOLPATH_SHARE_TARGET)", + )); +} + +#[test] +fn a_scheme_less_target_is_a_folder_not_a_bucket() { + let config = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["target", &folder.path().to_string_lossy()]) + .assert() + .success(); + let raw = std::fs::read_to_string(config.path().join("config.json")).unwrap(); + assert!( + raw.contains("file://"), + "a bare path must mean a folder: {raw}" + ); + assert!(!raw.contains("s3://"), "{raw}"); +} + +#[test] +fn an_unsupported_scheme_is_rejected() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["target", "gs://bucket/x"]) + .assert() + .failure() + .stderr(predicate::str::contains("s3://")); +} + +// ── path auth s3 ──────────────────────────────────────────────────── + +#[test] +fn auth_s3_login_stores_status_shows_and_logout_clears() { + let config = tempfile::tempdir().unwrap(); + + cmd(config.path()) + .args([ + "auth", + "s3", + "login", + "--region", + "eu-west-1", + "--access-key-id", + "AKIAEXAMPLE", + "--secret-access-key", + "supersecretvalue", + ]) + .assert() + .success() + .stdout(predicate::str::contains("S3 settings saved")) + // Credentials alone don't redirect `path share` — say so. + .stdout(predicate::str::contains("path target")); + + let stored = config.path().join("s3.json"); + assert!(stored.is_file(), "s3.json not written"); + + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&stored).unwrap().permissions().mode(); + assert_eq!( + mode & 0o777, + 0o600, + "credentials must not be world-readable" + ); + } + + cmd(config.path()) + .args(["auth", "s3", "status"]) + .assert() + .success() + .stdout(predicate::str::contains("eu-west-1")) + .stdout(predicate::str::contains("AKIAEXAMPLE")) + // The secret is stored but never printed back in full. + .stdout(predicate::str::contains("supersecretvalue").not()) + .stdout(predicate::str::contains("****alue")); + + cmd(config.path()) + .args(["auth", "s3", "logout"]) + .assert() + .success() + .stdout(predicate::str::contains("cleared")); + assert!(!stored.exists()); +} + +#[test] +fn auth_s3_login_merges_into_the_existing_settings() { + let config = tempfile::tempdir().unwrap(); + + cmd(config.path()) + .args(["auth", "s3", "login", "--access-key-id", "AKIAEXAMPLE"]) + .assert() + .success(); + // A later, narrower call must not wipe the key. + cmd(config.path()) + .args(["auth", "s3", "login", "--region", "us-west-2"]) + .assert() + .success(); + + let raw = std::fs::read_to_string(config.path().join("s3.json")).unwrap(); + assert!(raw.contains("AKIAEXAMPLE"), "{raw}"); + assert!(raw.contains("us-west-2"), "{raw}"); +} + +#[test] +fn auth_s3_status_marks_env_supplied_values() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "s3", "status"]) + .env("AWS_REGION", "ap-south-1") + .assert() + .success() + .stdout(predicate::str::contains("ap-south-1 (env)")); +} + +#[test] +fn auth_s3_login_without_a_terminal_or_flags_is_an_error() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["auth", "s3", "login"]) + .assert() + .failure() + .stderr(predicate::str::contains("Nothing to store")); +} + +#[test] +fn s3_credentials_without_a_default_refuse_to_publish_anonymously() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .args(["auth", "s3", "login", "--access-key-id", "AKIAEXAMPLE"]) + .assert() + .success(); + + // Not logged into Pathbase, S3 credentials present, no default: + // silently uploading to the anonymous public endpoint would be the + // worst possible guess. + cmd(config.path()) + .args(["p", "export", "object", "--input", doc.to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("path target")); +} + +// ── p export object / p import object ─────────────────────────────── + +#[test] +fn export_then_import_round_trips_through_a_folder() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + let out = cmd(config.path()) + .args(["p", "export", "object"]) + .args(["--input", doc.to_str().unwrap()]) + .args(["--to", &folder.path().to_string_lossy()]) + .assert() + .success(); + let uri = String::from_utf8(out.get_output().stdout.clone()) + .unwrap() + .trim() + .to_string(); + // Legible name: date and topic lead, cache id trails. The fixture + // is a 2026-01-01 session whose first prompt is "hello". + assert!( + uri.ends_with("/2026-01-01-hello-doc.json"), + "unexpected location: {uri}" + ); + assert!(folder.path().join("2026-01-01-hello-doc.json").is_file()); + + cmd(config.path()) + .args(["p", "import", "object", &format!("file://{uri}")]) + .assert() + .success(); + + let docs = config.path().join("documents"); + let ids: Vec = std::fs::read_dir(&docs) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(ids.len(), 1, "expected one cached doc, got {ids:?}"); + assert!(ids[0].starts_with("file-"), "unexpected cache id: {ids:?}"); +} + +#[test] +fn the_s3_subcommand_alias_still_works() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .args(["p", "export", "s3"]) + .args(["--input", doc.to_str().unwrap()]) + .args(["--to", &folder.path().to_string_lossy()]) + .assert() + .success(); + assert!(folder.path().join("2026-01-01-hello-doc.json").is_file()); +} + +#[test] +fn export_object_uses_the_configured_default_target() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .args(["target", &format!("{}/traces", folder.path().display())]) + .assert() + .success(); + + cmd(config.path()) + .args(["p", "export", "object", "--input", doc.to_str().unwrap()]) + .assert() + .success() + .stdout(predicate::str::contains( + "/traces/2026-01-01-hello-doc.json", + )); + + assert!( + folder + .path() + .join("traces/2026-01-01-hello-doc.json") + .is_file() + ); +} + +#[test] +fn export_object_with_a_pathbase_default_says_so() { + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .args(["target", "pathbase"]) + .assert() + .success(); + + cmd(config.path()) + .args(["p", "export", "object", "--input", doc.to_str().unwrap()]) + .assert() + .failure() + .stderr(predicate::str::contains("p export pathbase")); +} + +#[test] +fn import_object_reports_a_missing_object_clearly() { + let config = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + + cmd(config.path()) + .args([ + "p", + "import", + "object", + &format!("file://{}/nope.json", folder.path().display()), + ]) + .assert() + .failure() + .stderr(predicate::str::contains("not found")); +} + +#[test] +fn import_object_rejects_a_non_toolpath_object() { + let config = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + std::fs::write(folder.path().join("junk.json"), "{\"hello\":1}").unwrap(); + + cmd(config.path()) + .args([ + "p", + "import", + "object", + &format!("file://{}/junk.json", folder.path().display()), + ]) + .assert() + .failure() + .stderr(predicate::str::contains("not a toolpath document")); +} + +// ── path share ────────────────────────────────────────────────────── + +#[test] +fn a_shared_object_keeps_its_name_when_the_session_is_re_shared() { + // Re-sharing must overwrite its own object, not leave a trail of + // near-duplicates — the reason every part of the name is a pure + // function of the document. + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let folder = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + for _ in 0..2 { + cmd(config.path()) + .args(["p", "export", "object"]) + .args(["--input", doc.to_str().unwrap()]) + .args(["--to", &folder.path().to_string_lossy()]) + .assert() + .success(); + } + + let objects: Vec = std::fs::read_dir(folder.path()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + assert_eq!(objects, vec!["2026-01-01-hello-doc.json".to_string()]); +} + +#[test] +fn a_bare_relative_target_is_rejected_rather_than_creating_a_folder() { + // The trap: a bucket name typed from memory becomes ./my-bucket + // under the cwd, and the share reports success. + let config = tempfile::tempdir().unwrap(); + let work = tempfile::tempdir().unwrap(); + let doc = write_doc(work.path()); + + cmd(config.path()) + .current_dir(work.path()) + .args(["p", "export", "object"]) + .args(["--input", doc.to_str().unwrap()]) + .args(["--to", "my-bucket/traces"]) + .assert() + .failure() + .stderr(predicate::str::contains("s3://my-bucket/traces")) + .stderr(predicate::str::contains("./my-bucket/traces")); + + assert!( + !work.path().join("my-bucket").exists(), + "a rejected target must not leave a directory behind" + ); +} + +#[test] +fn target_rejects_a_bare_relative_value_too() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["target", "my-bucket/traces"]) + .assert() + .failure() + .stderr(predicate::str::contains("s3://my-bucket/traces")); +} + +#[test] +fn share_rejects_pathbase_flags_alongside_an_object_target() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["share", "--to", "s3://b/p", "--public"]) + .assert() + .failure() + .stderr(predicate::str::contains("--to pathbase")); +} + +#[test] +fn share_rejects_an_unparseable_target_before_doing_any_work() { + let config = tempfile::tempdir().unwrap(); + cmd(config.path()) + .args(["share", "--to", "gs://bucket/x"]) + .assert() + .failure() + .stderr(predicate::str::contains("s3://")); +} diff --git a/crates/toolpath-cli/Cargo.toml b/crates/toolpath-cli/Cargo.toml index 9b38ca37..5fd5d044 100644 --- a/crates/toolpath-cli/Cargo.toml +++ b/crates/toolpath-cli/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "toolpath-cli" -version = "0.16.1" +version = "0.17.0" edition = "2024" license = "Apache-2.0" repository = "https://github.com/empathic/toolpath" @@ -14,7 +14,7 @@ name = "path" path = "src/main.rs" [dependencies] -path-cli = { path = "../path-cli", version = "0.16.1" } +path-cli = { path = "../path-cli", version = "0.17.0" } anyhow = "1.0" [workspace] diff --git a/site/_data/crates.json b/site/_data/crates.json index 4d282a97..d7982a6f 100644 --- a/site/_data/crates.json +++ b/site/_data/crates.json @@ -113,15 +113,15 @@ }, { "name": "path-cli", - "version": "0.16.1", + "version": "0.17.0", "description": "Unified CLI (binary: path)", "docs": "https://docs.rs/path-cli", "crate": "https://crates.io/crates/path-cli", - "role": "One binary called `path` that ties everything together. Porcelain at the top level (share, resume, query, show, track, auth); plumbing under `path p \u2026` (import, export, cache, list, render, merge, validate). Pathbase round-trip via `p import pathbase` / `p export pathbase` (authed default \u2192 secret pathstash; anon fallback when not logged in)." + "role": "One binary called `path` that ties everything together. Porcelain at the top level (share, resume, query, show, track, auth); plumbing under `path p \u2026` (import, export, cache, list, render, merge, validate). Pathbase round-trip via `p import pathbase` / `p export pathbase` (authed default \u2192 secret pathstash; anon fallback when not logged in). Share targets are configurable: `path auth default ` points `share` at an S3 bucket, an S3-compatible endpoint, or a plain folder; `resume` takes the location back." }, { "name": "toolpath-cli", - "version": "0.16.1", + "version": "0.17.0", "description": "Deprecated alias for path-cli", "docs": "https://docs.rs/toolpath-cli", "crate": "https://crates.io/crates/toolpath-cli",