feat(cli): file share / file get + same-host fast path (v1.19.0)
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:
@@ -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",
|
||||
|
||||
@@ -451,12 +451,30 @@ claudemesh webhook delete <name>
|
||||
### `file` — shared mesh files
|
||||
|
||||
```bash
|
||||
claudemesh file list [search-query] # list files
|
||||
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
|
||||
|
||||
166
apps/cli/src/commands/file.ts
Normal file
166
apps/cli/src/commands/file.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
@@ -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}"`);
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user