feat(cli): file share / file get + same-host fast path (v1.19.0)
Some checks failed
CI / Lint (push) Has been cancelled
CI / Typecheck (push) Has been cancelled
CI / Broker tests (Postgres) (push) Has been cancelled
CI / Docker build (linux/amd64) (push) Has been cancelled

Two new CLI verbs for the file-sharing surface that already existed
on the broker (HTTP /upload + WS get_file/list_files) but was only
reachable through MCP-style docstrings referencing tools that do
not in fact exist:

  claudemesh file share <path> [--to peer] [--message "..."]
  claudemesh file get <id> [--out path]

Same-host fast path: when --to resolves to a session on the same
hostname, skip MinIO and DM the absolute filepath. The receiver
reads it off disk directly. No bucket roundtrip, no 50 MB cap.
Falls back to encrypted upload when the peer is remote or --upload
is set.

Routes the same-host DM by session pubkey, not displayName, so
sibling sessions of the same member do not trip the v0.5.1
self-DM guard.

Updates the bundled SKILL.md and the MCP server instructions to
reference the real CLI verbs instead of the fictional share_file()
/ get_file() tool calls.

Also: rename.ts now distinguishes mesh-membership from web-account
auth and points users at claudemesh login + the dashboard rather
than emitting a bare "Not signed in".

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 14:05:24 +01:00
parent f9ed3fa286
commit 3a3d2a6c4c
7 changed files with 246 additions and 12 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "claudemesh-cli",
"version": "1.18.0",
"version": "1.19.0",
"description": "Peer mesh for Claude Code sessions — CLI + MCP server.",
"keywords": [
"claude-code",

View File

@@ -451,12 +451,30 @@ claudemesh webhook delete <name>
### `file` — shared mesh files
```bash
claudemesh file list [search-query] # list files
claudemesh file status <file-id> # who has accessed
claudemesh file share <path> # upload to mesh (visible to all members)
claudemesh file share <path> --to <peer> # share with one peer (same-host fast path if co-located)
claudemesh file share <path> --to <peer> --message "see line 42"
claudemesh file share <path> --upload # force network upload, skip same-host fast path
claudemesh file get <file-id> # download by id (saves to ./<name>)
claudemesh file get <file-id> --out /tmp/foo.bin # download to explicit path
claudemesh file list [search-query] # browse mesh files
claudemesh file status <file-id> # who has accessed
claudemesh file delete <file-id>
# Upload + retrieval currently via MCP `share_file` / `get_file` (binary streams)
```
**Same-host fast path** (v0.6.0+): when `--to <peer>` resolves to a session
running on the same hostname as you, `claudemesh file share` skips MinIO
entirely and sends a DM with the absolute filepath. The receiver reads it
directly off disk. No 50 MB cap, no upload latency, nothing in the bucket.
Falls back to encrypted upload when the peer is remote, or always when
`--upload` is set. Routes by session pubkey, so sibling sessions of the
same member work without tripping the self-DM guard.
**Network upload cap**: 50 MB. Same-host fast path has no cap.
**`--to` accepts**: display name, member pubkey, session pubkey, or any
≥8-char prefix of a pubkey. Prefer pubkey when multiple peers share a name.
### `mesh-mcp` — call MCP servers other peers deployed to the mesh
```bash

View File

@@ -0,0 +1,166 @@
/**
* `claudemesh file share <path>` — upload a file to the mesh.
* `claudemesh file get <id>` — download a file by id.
*
* Same-host fast path: when `--to <peer>` is provided and the target
* peer's `hostname` matches this machine's, we skip the MinIO upload
* entirely and send a DM containing the absolute path. The receiver
* reads it directly off the local filesystem. Saves bandwidth + bucket
* space for the common "two Claude sessions on the same laptop" case.
*
* Falls back to encrypted MinIO upload + grant when:
* - `--to` not provided (sharing with the whole mesh)
* - target peer is on a different host
* - `--upload` flag forces the network path
*/
import { hostname as osHostname } from "node:os";
import { resolve as resolvePath, basename, dirname } from "node:path";
import { statSync, existsSync, writeFileSync, mkdirSync } from "node:fs";
import { withMesh } from "./connect.js";
import { render } from "~/ui/render.js";
import { bold, dim, green } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
// Broker enforces 50 MB on /upload (apps/broker/src/index.ts ~line 1204).
// We mirror it client-side so users get a clear error before bytes go on the wire.
const MAX_FILE_BYTES = 50 * 1024 * 1024;
type Flags = {
mesh?: string;
json?: boolean;
to?: string;
tags?: string;
out?: string;
upload?: boolean; // force network upload, skip same-host fast path
message?: string; // optional note attached to the share DM
};
function emitJson(data: unknown): void {
console.log(JSON.stringify(data, null, 2));
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
export async function runFileShare(filePath: string, opts: Flags): Promise<number> {
if (!filePath) {
render.err("Usage: claudemesh file share <path> [--to <peer>] [--tags a,b] [--message \"...\"] [--upload]");
return EXIT.INVALID_ARGS;
}
const absPath = resolvePath(filePath);
if (!existsSync(absPath)) {
render.err(`File not found: ${absPath}`);
return EXIT.INVALID_ARGS;
}
const stat = statSync(absPath);
if (!stat.isFile()) {
render.err(`Not a regular file: ${absPath}`);
return EXIT.INVALID_ARGS;
}
// Network upload has a 50 MB cap (broker-enforced). The same-host fast
// path doesn't transfer bytes — it sends a filepath — so it has no cap.
const tags = opts.tags ? opts.tags.split(",").map((t) => t.trim()).filter(Boolean) : [];
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client, mesh) => {
// ── Same-host fast path ─────────────────────────────────────────────
// If --to points at a peer running on this same machine, just DM the
// absolute path. No upload, no MinIO, no presigned URLs.
if (opts.to && !opts.upload) {
const peers = await client.listPeers();
const myHost = osHostname();
const target = peers.find((p) => {
if (!p.hostname || p.hostname !== myHost) return false;
return (
p.displayName === opts.to ||
(p as { memberPubkey?: string }).memberPubkey === opts.to ||
p.pubkey === opts.to ||
(typeof opts.to === "string" && opts.to.length >= 8 && p.pubkey.startsWith(opts.to))
);
});
if (target) {
const note = opts.message ? `\n${opts.message}` : "";
const body = `📎 file://${absPath} (${formatSize(stat.size)} · same host, no upload)${note}`;
// Route by session pubkey, not displayName — sibling sessions of
// the same member share the displayName (and the v0.5.1 self-DM
// guard would otherwise reject sends targeting our own member).
const result = await client.send(target.pubkey, body, "next");
if (!result.ok) {
render.err(`Send failed: ${result.error ?? "unknown"}`);
return EXIT.NETWORK_ERROR;
}
if (opts.json) {
emitJson({ mode: "local", path: absPath, to: target.displayName, hostname: myHost, sizeBytes: stat.size });
} else {
render.ok(`shared ${bold(basename(absPath))} ${dim(`(${formatSize(stat.size)})`)}${green(target.displayName)} ${dim("[same host, no upload]")}`);
}
return EXIT.SUCCESS;
}
// No same-host match — fall through to upload path.
}
// ── Network upload path ─────────────────────────────────────────────
const fileId = await client.uploadFile(absPath, mesh.meshId, mesh.memberId, {
name: basename(absPath),
tags,
persistent: true,
targetSpec: opts.to,
});
// If --to was set, drop a DM so the recipient is notified + has the id.
if (opts.to) {
const note = opts.message ? `\n${opts.message}` : "";
const body = `📎 ${basename(absPath)} (${formatSize(stat.size)})\nclaudemesh file get ${fileId}${note}`;
await client.send(opts.to, body, "next");
}
if (opts.json) {
emitJson({ mode: "upload", fileId, name: basename(absPath), sizeBytes: stat.size, to: opts.to ?? null });
} else {
render.ok(`uploaded ${bold(basename(absPath))} ${dim(`(${formatSize(stat.size)})`)} ${dim("· id=" + fileId.slice(0, 12))}`);
if (opts.to) render.info(dim(` notified ${opts.to}`));
else render.info(dim(` retrieve: claudemesh file get ${fileId}`));
}
return EXIT.SUCCESS;
});
}
export async function runFileGet(fileId: string, opts: Flags): Promise<number> {
if (!fileId) {
render.err("Usage: claudemesh file get <file-id> [--out <path>]");
return EXIT.INVALID_ARGS;
}
return await withMesh({ meshSlug: opts.mesh ?? null }, async (client) => {
const meta = await client.getFile(fileId);
if (!meta) {
render.err(`File not found or not accessible: ${fileId}`);
return EXIT.NOT_FOUND;
}
const res = await fetch(meta.url, { signal: AbortSignal.timeout(60_000) });
if (!res.ok) {
render.err(`Download failed: HTTP ${res.status}`);
return EXIT.NETWORK_ERROR;
}
const buf = Buffer.from(await res.arrayBuffer());
const outPath = opts.out
? resolvePath(opts.out)
: resolvePath(process.cwd(), meta.name);
mkdirSync(dirname(outPath), { recursive: true });
writeFileSync(outPath, buf);
if (opts.json) {
emitJson({ fileId, name: meta.name, savedTo: outPath, sizeBytes: buf.length });
} else {
render.ok(`saved ${bold(meta.name)} ${dim(`(${formatSize(buf.length)})`)}${dim(outPath)}`);
}
return EXIT.SUCCESS;
});
}

View File

@@ -1,8 +1,22 @@
import { rename as renameMesh } from "~/services/mesh/facade.js";
import { green, icons } from "~/ui/styles.js";
import { getStoredToken } from "~/services/auth/facade.js";
import { bold, dim, green, icons } from "~/ui/styles.js";
import { EXIT } from "~/constants/exit-codes.js";
export async function rename(slug: string, newName: string): Promise<number> {
// Rename hits the account-scoped /api/my/meshes endpoint, which requires a
// web session token (~/.claudemesh/auth.json). Joining a mesh via invite
// does NOT create that token — it only writes a per-mesh apikey to
// config.json. Detect this case up front so the error is actionable.
const auth = getStoredToken();
if (!auth) {
console.error(` ${icons.cross} Renaming a mesh requires a claudemesh.com account session.`);
console.error(` ${dim("Joining via invite signs you in to the mesh, not to a web account.")}`);
console.error(` ${dim("Run")} ${bold("claudemesh login")} ${dim("first, then retry, or rename from the dashboard:")}`);
console.error(` https://claudemesh.com/dashboard`);
return EXIT.AUTH_FAILED;
}
try {
await renameMesh(slug, newName);
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);

View File

@@ -159,6 +159,8 @@ Platform
claudemesh vault list|delete encrypted secrets
claudemesh watch list|remove URL change watchers
claudemesh webhook list|delete outbound HTTP triggers
claudemesh file share <path> [--to peer] upload (or local-host fast path if --to matches)
claudemesh file get <id> [--out path] download by id
claudemesh file list|status|delete shared mesh files
claudemesh mesh-mcp list|call|catalog deployed mesh-MCP servers
claudemesh clock set|pause|resume mesh logical clock
@@ -576,10 +578,24 @@ async function main(): Promise<void> {
case "file": {
const sub = positionals[0];
const f = { mesh: flags.mesh as string, json: !!flags.json };
if (sub === "list") { const { runFileList } = await import("~/commands/platform-actions.js"); process.exit(await runFileList({ ...f, query: positionals[1] })); }
if (sub === "share") {
const { runFileShare } = await import("~/commands/file.js");
process.exit(await runFileShare(positionals[1] ?? "", {
...f,
to: flags.to as string | undefined,
tags: flags.tags as string | undefined,
message: flags.message as string | undefined,
upload: !!flags.upload,
}));
}
else if (sub === "get") {
const { runFileGet } = await import("~/commands/file.js");
process.exit(await runFileGet(positionals[1] ?? "", { ...f, out: flags.out as string | undefined }));
}
else if (sub === "list") { const { runFileList } = await import("~/commands/platform-actions.js"); process.exit(await runFileList({ ...f, query: positionals[1] })); }
else if (sub === "status") { const { runFileStatus } = await import("~/commands/platform-actions.js"); process.exit(await runFileStatus(positionals[1] ?? "", f)); }
else if (sub === "delete") { const { runFileDelete } = await import("~/commands/platform-actions.js"); process.exit(await runFileDelete(positionals[1] ?? "", f)); }
else { console.error("Usage: claudemesh file <list|status|delete>"); process.exit(EXIT.INVALID_ARGS); }
else { console.error("Usage: claudemesh file <share|get|list|status|delete>"); process.exit(EXIT.INVALID_ARGS); }
break;
}
case "mesh-mcp": {

View File

@@ -324,11 +324,11 @@ If the channel meta contains \`subtype: reminder\`, this is a scheduled reminder
| remember(content, tags?) | Store persistent knowledge with optional tags. |
| recall(query) | Full-text search over mesh memory. |
| forget(id) | Soft-delete a memory entry. |
| share_file(path, name?, tags?) | Share a persistent file with the mesh. |
| get_file(id, save_to) | Download a shared file to a local path. |
| list_files(query?, from?) | Find files shared in the mesh. |
| file_status(id) | Check who has accessed a file. |
| delete_file(id) | Remove a shared file from the mesh. |
| claudemesh file share <path> [--to peer] [--tags a,b] | Share a file with the mesh, or DM it to a specific peer. Same-host fast path: when --to matches a peer on this machine, sends an absolute filepath instead of uploading (no MinIO round-trip). |
| claudemesh file get <id> [--out path] | Download a shared file by id. |
| claudemesh file list [query] | Find files shared in the mesh. |
| claudemesh file status <id> | Check who has accessed a file. |
| claudemesh file delete <id> | Remove a shared file from the mesh. |
| vector_store(collection, text, metadata?) | Store embedding in per-mesh Qdrant collection. |
| vector_search(collection, query, limit?) | Semantic search over stored embeddings. |
| vector_delete(collection, id) | Remove an embedding. |

View File

@@ -298,6 +298,26 @@ level, or wire claudemesh to messaging surfaces beyond Claude Code.
default returns last 30d). CLI: omitting `--mesh` on each
verb routes through the matching aggregator. *Shipped
2026-05-03 in CLI v1.16.0.*
- **v0.6.0 — `claudemesh file share / get` + same-host fast path** —
CLI parity for the file-sharing surface that was already on the
broker side (HTTP `/upload`, WS `get_file` / `list_files`) but
reachable only through MCP-style docstrings that referenced
unimplemented tools. Two new verbs:
- `claudemesh file share <path> [--to peer] [--message "..."] [--upload]`
- `claudemesh file get <id> [--out path]`
When `--to <peer>` resolves to a session running on the same
hostname, the CLI skips MinIO entirely and DMs the absolute
filepath — receiver reads it directly off disk. Saves bandwidth
and bucket space for the common "two Claude sessions on one
laptop" case. Falls back to encrypted upload when the target is
remote, when sharing with the whole mesh (no `--to`), or when
`--upload` forces it. Cap: 50 MB on the network path (broker-
enforced); same-host fast path has no cap (no bytes traverse).
Routes the DM by session pubkey (not displayName) so sibling
sessions of the same member work without tripping the v0.5.1
self-DM guard. Updates the MCP `instructions` block to
reference these CLI verbs instead of fictional `share_file()` /
`get_file()` tools. *Shipped 2026-05-03 in CLI v1.19.0.*
- **v0.5.2 — `claudemesh skill` prints the bundled SKILL.md** —
zero-install access for the protocol reference. SKILL.md is
embedded into the CLI bundle at build time via Bun's