Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 41 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,47 @@ All notable changes to this project are documented here.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [Unreleased]

### Added

- **`pb manifest` — the record of what this machine uses, with no credential in
it.** `manifest.json` already existed and already had the right shape: no
secret by construction, and the thing `pb plan --manifest` plans against. But
it only ever existed *inside* an encrypted bundle, so getting the readable
half meant producing the dangerous half first and then unpacking it. The one
artifact that was safe to commit was the one you could not get without
encrypting every credential on the machine.

`pb manifest` writes it on its own, to stdout or `-o <file>`. It opens no
credential file — not an optimisation, but the point: reading every
credential to produce a file that will hold none of them is exactly the
handling this command exists to avoid. The vault is listed and never
unlocked, MCP servers are named with their env/header variable NAMES and
never their values, and `carried` is empty everywhere because nothing was
carried.

Manifests now say which kind they are — `"kind": "inventory"` here,
`"bundle"` inside an export, defaulting to `bundle` so an older file still
reads. An inventory that claimed things had travelled would be the one lie
this format must never tell.

The intended shape: keep it in a repo you sync, and a new machine's whole
setup is `pb plan --manifest setup/manifest.json` — install this, log into
that — or the same list over MCP, worked one item at a time by an agent.

- **`write_manifest` MCP tool.** The one part of a machine move an agent can do
unsupervised, because it touches no credential. `pb export` and `pb import`
stay in the CLI, where the human and the passphrase are.

### Fixed

- **MCP records no longer claim to have been carried when they were not.**
`collect_mcp` marked a registration `carried: true` whenever its spec was
readable, which was true for a bundle and wrong for anything that does not
carry values. Found while building the inventory path; it never affected a
real export, where the two happened to coincide.

## [0.5.0] - 2026-08-28

### Added
Expand Down
16 changes: 15 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
- **[Key vault](docs/key-vault.md)** — standalone API keys no CLI tracks: values in the macOS Keychain, metadata on disk, provider-aware `pb key verify`, and AI registration over MCP.
- **[Project env vault](docs/env-vault.md)** — a project's environment variables without a plaintext `.env`: pull from Infisical, keep hand-set local overrides that never sync back, run a command with the merged result. A project is a portable name, not a path — `pb export` carries the manifest to a new machine (or copy the one file), clone the repo, pull.
- **[Keeping CLIs current](#keeping-clis-current)** — which tools are outdated, which were renamed out from under you, and the exact command to update each one.
- **[Migrate](docs/migration.md)** — export to a new machine; whatever can't travel, your AI walks you through re-authing.
- **[Migrate](docs/migration.md)** — export to a new machine; whatever can't travel, your AI walks you through re-authing. Or `pb manifest`: the secret-free record of what you use, safe to commit, and enough for an agent to rebuild a machine from.

## Install

Expand Down Expand Up @@ -105,6 +105,20 @@ pb import patchbay-*.pbx # --dry-run first; existing files are backed up
pb plan # what's left, with the exact command for each
```

Or carry no credential at all:

```sh
pb manifest -o setup/manifest.json # the record of what this machine uses
pb plan --manifest setup/manifest.json # …on the new machine: install this, log into that
```

`pb manifest` writes the readable half on its own — which CLIs you use, which
accounts are active, what is in the key vault, which MCP servers are registered.
**No secret value is in it and no credential file is even opened**, so it is
meant to be committed and synced. On a new machine it is the input your agent
plans against, which is the difference between "set this laptop up" and "set
this laptop up like the last one".

Files that work anywhere get copied (`gcloud`, `aws`, `kubectl`, `wrangler`,
`rclone`, `npm`, `docker`, `ssh` config…). Credentials the OS keychain or the
device itself is holding can't, and patchbay says so instead of pretending —
Expand Down
7 changes: 7 additions & 0 deletions crates/patchbay-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,12 @@ enum Command {
#[arg(long)]
json: bool,
},
/// Write the secret-free record of what this machine uses.
Manifest {
/// Where to write it. Defaults to stdout.
#[arg(long, short)]
out: Option<std::path::PathBuf>,
},
/// Restore a bundle onto this machine.
Import {
bundle: std::path::PathBuf,
Expand Down Expand Up @@ -301,6 +307,7 @@ fn run() -> Result<i32> {
},
&styles(),
),
Command::Manifest { out } => migrate::run(migrate::Command::Manifest { out }, &styles()),
Command::Import {
bundle,
dry_run,
Expand Down
60 changes: 60 additions & 0 deletions crates/patchbay-cli/src/migrate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ pub enum Command {
#[arg(long)]
json: bool,
},
/// Write the secret-free record of what this machine uses.
Manifest {
/// Where to write it. Defaults to stdout.
#[arg(long, short)]
out: Option<PathBuf>,
},
/// Restore a bundle onto this machine.
Import {
bundle: PathBuf,
Expand Down Expand Up @@ -105,6 +111,32 @@ pub fn run(command: Command, styles: &Styles) -> Result<i32> {
Ok(0)
}

Command::Manifest { out } => {
let manifest = Exporter {
paths: &paths,
registry: &registry,
vault: &vault,
clients: &clients,
envs: &envs,
}
.manifest(Utc::now())?;

// No passphrase, no cloud-folder check, no warning about moving the
// file carefully: this one is meant to be committed and synced.
// Those guards exist for bundles, and repeating them here would
// teach people to ignore them where they matter.
let json = manifest.to_json();
match out {
Some(path) => {
std::fs::write(&path, format!("{json}\n"))
.with_context(|| format!("writing {}", path.display()))?;
print_manifest(&manifest, &path, styles);
}
None => println!("{json}"),
}
Ok(0)
}

Command::Import {
bundle,
dry_run,
Expand Down Expand Up @@ -307,6 +339,34 @@ fn print_export(report: &export::ExportReport, styles: &Styles) {
);
}

/// Written-to-a-file summary. Deliberately counts rather than lists: the file
/// itself is the listing, and a wall of tool names between the command and the
/// path buries the one line the reader needs.
fn print_manifest(manifest: &Manifest, path: &std::path::Path, styles: &Styles) {
let installed = manifest.tools.iter().filter(|t| t.installed).count();
println!("wrote {}", path.display());
println!(
" {installed} CLI(s) installed, {} key(s), {} MCP registration(s), {} env project(s)",
manifest.keys.len(),
manifest.mcp.len(),
manifest.env_projects.len(),
);
println!(
" {}",
styles.paint(
dim_style(),
"no secret value is in this file — commit it, sync it, hand it to an agent"
)
);
println!(
" {}",
styles.paint(
dim_style(),
"on the new machine: pb plan --manifest <this file>"
)
);
}

fn file_name(path: &std::path::Path) -> String {
path.file_name()
.map(|n| n.to_string_lossy().into_owned())
Expand Down
2 changes: 2 additions & 0 deletions crates/patchbay-core/src/migrate/bundle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ pub fn read(path: &Path, passphrase: &str) -> anyhow::Result<Payload> {
#[cfg(test)]
mod tests {
use super::*;
use crate::migrate::manifest::ManifestKind;
use crate::migrate::manifest::Source;
use chrono::{DateTime, Utc};

Expand All @@ -342,6 +343,7 @@ mod tests {
version: BUNDLE_VERSION,
manifest: Manifest {
version: BUNDLE_VERSION,
kind: ManifestKind::default(),
created_at: DateTime::parse_from_rfc3339("2026-08-13T00:00:00Z")
.unwrap()
.with_timezone(&Utc),
Expand Down
Loading
Loading