diff --git a/apps/cli/package.json b/apps/cli/package.json index 2088cc7..b3aefad 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -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", diff --git a/apps/cli/skills/claudemesh/SKILL.md b/apps/cli/skills/claudemesh/SKILL.md index daebded..c4f298a 100644 --- a/apps/cli/skills/claudemesh/SKILL.md +++ b/apps/cli/skills/claudemesh/SKILL.md @@ -451,12 +451,30 @@ claudemesh webhook delete ### `file` — shared mesh files ```bash -claudemesh file list [search-query] # list files -claudemesh file status # who has accessed +claudemesh file share # upload to mesh (visible to all members) +claudemesh file share --to # share with one peer (same-host fast path if co-located) +claudemesh file share --to --message "see line 42" +claudemesh file share --upload # force network upload, skip same-host fast path +claudemesh file get # download by id (saves to ./) +claudemesh file get --out /tmp/foo.bin # download to explicit path +claudemesh file list [search-query] # browse mesh files +claudemesh file status # who has accessed claudemesh file delete -# Upload + retrieval currently via MCP `share_file` / `get_file` (binary streams) ``` +**Same-host fast path** (v0.6.0+): when `--to ` 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 diff --git a/apps/cli/src/commands/file.ts b/apps/cli/src/commands/file.ts new file mode 100644 index 0000000..a4d9775 --- /dev/null +++ b/apps/cli/src/commands/file.ts @@ -0,0 +1,166 @@ +/** + * `claudemesh file share ` — upload a file to the mesh. + * `claudemesh file get ` — download a file by id. + * + * Same-host fast path: when `--to ` 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 { + if (!filePath) { + render.err("Usage: claudemesh file share [--to ] [--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 { + if (!fileId) { + render.err("Usage: claudemesh file get [--out ]"); + 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; + }); +} diff --git a/apps/cli/src/commands/rename.ts b/apps/cli/src/commands/rename.ts index 5b8e4df..00a3724 100644 --- a/apps/cli/src/commands/rename.ts +++ b/apps/cli/src/commands/rename.ts @@ -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 { + // 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}"`); diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 32a628e..7ddfd2a 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -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 [--to peer] upload (or local-host fast path if --to matches) + claudemesh file get [--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 { 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 "); process.exit(EXIT.INVALID_ARGS); } + else { console.error("Usage: claudemesh file "); process.exit(EXIT.INVALID_ARGS); } break; } case "mesh-mcp": { diff --git a/apps/cli/src/mcp/server.ts b/apps/cli/src/mcp/server.ts index 2e7fde3..08b9105 100644 --- a/apps/cli/src/mcp/server.ts +++ b/apps/cli/src/mcp/server.ts @@ -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 [--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 [--out path] | Download a shared file by id. | +| claudemesh file list [query] | Find files shared in the mesh. | +| claudemesh file status | Check who has accessed a file. | +| claudemesh file delete | 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. | diff --git a/docs/roadmap.md b/docs/roadmap.md index 4ce5345..899171c 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -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 [--to peer] [--message "..."] [--upload]` + - `claudemesh file get [--out path]` + When `--to ` 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