diff --git a/apps/cli/package.json b/apps/cli/package.json index d764829..addd4e5 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,6 +1,6 @@ { "name": "claudemesh-cli", - "version": "1.20.1", + "version": "1.21.0", "description": "Peer mesh for Claude Code sessions — CLI + MCP server.", "keywords": [ "claude-code", diff --git a/apps/cli/src/commands/delete-mesh.ts b/apps/cli/src/commands/delete-mesh.ts index 39555fc..12e0087 100644 --- a/apps/cli/src/commands/delete-mesh.ts +++ b/apps/cli/src/commands/delete-mesh.ts @@ -45,7 +45,7 @@ export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Pr } render.section("select mesh to remove"); config.meshes.forEach((m, i) => { - process.stdout.write(` ${bold(String(i + 1) + ")")} ${clay(m.slug)} ${dim("(" + m.name + ")")}\n`); + process.stdout.write(` ${bold(String(i + 1) + ")")} ${clay(m.slug)}\n`); }); render.blank(); const choice = await prompt(` ${dim("choice:")} `); diff --git a/apps/cli/src/commands/invite.ts b/apps/cli/src/commands/invite.ts index 61b6b2c..7b37eb0 100644 --- a/apps/cli/src/commands/invite.ts +++ b/apps/cli/src/commands/invite.ts @@ -39,7 +39,7 @@ export async function invite( // Show picker console.log("\n Select mesh to share:\n"); config.meshes.forEach((m, i) => { - console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`); + console.log(` ${bold(String(i + 1) + ")")} ${m.slug}`); }); console.log(""); const choice = await prompt(" Choice [1]: ") || "1"; diff --git a/apps/cli/src/commands/launch.ts b/apps/cli/src/commands/launch.ts index 4ffd6b6..216f8f6 100644 --- a/apps/cli/src/commands/launch.ts +++ b/apps/cli/src/commands/launch.ts @@ -49,8 +49,7 @@ async function pickMesh(meshes: JoinedMesh[]): Promise { console.log("\n Select mesh:"); meshes.forEach((m, i) => { - const label = m.name && m.name !== m.slug ? `${m.name} \x1b[2m(${m.slug})\x1b[0m` : m.slug; - console.log(` ${i + 1}) ${label}`); + console.log(` ${i + 1}) ${m.slug}`); }); console.log(""); @@ -219,9 +218,7 @@ async function runLaunchWizard(opts: { spinner.stop(); const choice = await menuSelect({ title: "Select mesh", - items: opts.meshes.map((m) => - m.name && m.name !== m.slug ? `${m.name} \x1b[2m(${m.slug})\x1b[0m` : m.slug, - ), + items: opts.meshes.map((m) => m.slug), row, }); mesh = opts.meshes[choice]!; @@ -229,8 +226,7 @@ async function runLaunchWizard(opts: { for (let i = 0; i < opts.meshes.length + 1; i++) { writeCentered(row + i, " "); } - const meshLabel = mesh.name && mesh.name !== mesh.slug ? `${mesh.name} (${mesh.slug})` : mesh.slug; - writeCentered(row, `Mesh ${tGreen("✓")} ${meshLabel}`); + writeCentered(row, `Mesh ${tGreen("✓")} ${mesh.slug}`); spinner.start(); row++; } diff --git a/apps/cli/src/commands/rename.ts b/apps/cli/src/commands/rename.ts index 3218b20..d41ad29 100644 --- a/apps/cli/src/commands/rename.ts +++ b/apps/cli/src/commands/rename.ts @@ -1,44 +1,69 @@ -import { rename as renameMesh } from "~/services/mesh/facade.js"; +/** + * `claudemesh rename ` — change a mesh's identifier. + * + * v0.7.0 collapse: slug IS the identifier — there is no separate + * "display name". Pre-launch we collapsed the model so users only ever + * deal with one identifier per mesh. The mesh.name column on the DB is + * kept for now (avoids touching ~25 reader sites) but is always synced + * to slug; a follow-up migration drops it. + */ + +import { reslug as reslugMesh } from "~/services/mesh/facade.js"; import { getStoredToken } from "~/services/auth/facade.js"; import { ApiError } from "~/services/api/facade.js"; -import { readConfig, setMeshConfig } from "~/services/config/facade.js"; +import { readConfig, setMeshConfig, removeMeshConfig } from "~/services/config/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 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 SLUG_RE = /^[a-z0-9][a-z0-9-]{1,31}$/; + +export async function rename(oldSlug: string, newSlug: string): Promise { + if (!oldSlug || !newSlug) { + console.error(` ${icons.cross} Usage: ${bold("claudemesh rename")} `); + return EXIT.INVALID_ARGS; + } + if (!SLUG_RE.test(newSlug)) { + console.error(` ${icons.cross} Invalid slug: must be 2-32 chars, lowercase alnum + hyphens, start with alnum`); + return EXIT.INVALID_ARGS; + } + if (oldSlug === newSlug) { + console.error(` ${icons.cross} Old and new slug are the same.`); + return EXIT.INVALID_ARGS; + } + 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`); + console.error(` ${dim("Run")} ${bold("claudemesh login")} ${dim("first.")}`); return EXIT.AUTH_FAILED; } + const cfg = readConfig(); + const collision = cfg.meshes.find((m) => m.slug === newSlug && m.slug !== oldSlug); + if (collision) { + console.error(` ${icons.cross} Slug "${newSlug}" already used locally by another joined mesh.`); + console.error(` ${dim("Pick a different slug, or leave the other mesh first.")}`); + return EXIT.ALREADY_EXISTS; + } + try { - await renameMesh(slug, newName); - // Sync the new name into local config so launch / picker - // reflect the change without waiting for the next sync. - const cfg = readConfig(); - const local = cfg.meshes.find((m) => m.slug === slug); - if (local) setMeshConfig(slug, { ...local, name: newName }); - console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`); - console.log(` ${dim("(slug stays \"" + slug + "\" — only the display name changed)")}`); + const updated = await reslugMesh(oldSlug, newSlug); + const local = cfg.meshes.find((m) => m.slug === oldSlug); + if (local) { + removeMeshConfig(oldSlug); + setMeshConfig(updated.slug, { ...local, slug: updated.slug, name: updated.slug }); + } + console.log(` ${green(icons.check)} Renamed: "${oldSlug}" → "${updated.slug}"`); + console.log(` ${dim("Other peers will pick up the new identifier after they run")} ${bold("claudemesh sync")}`); return EXIT.SUCCESS; } catch (err) { if (err instanceof ApiError) { - // Server returns { error: "..." } for actionable messages - // (not the owner, mesh missing, expired token). Surface the - // body — the bare HTTP code alone hides why it failed. const body = err.body as { error?: string } | undefined; - const detail = body?.error ?? err.statusText; - console.error(` ${icons.cross} ${detail}`); + console.error(` ${icons.cross} ${body?.error ?? err.statusText}`); if (err.status === 401) return EXIT.AUTH_FAILED; if (err.status === 403) return EXIT.PERMISSION_DENIED; if (err.status === 404) return EXIT.NOT_FOUND; + if (err.status === 400) return EXIT.INVALID_ARGS; return EXIT.INTERNAL_ERROR; } console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`); diff --git a/apps/cli/src/commands/slug.ts b/apps/cli/src/commands/slug.ts deleted file mode 100644 index cb225be..0000000 --- a/apps/cli/src/commands/slug.ts +++ /dev/null @@ -1,83 +0,0 @@ -/** - * `claudemesh slug ` — change a mesh's slug. - * - * Slugs are NOT globally unique (mesh.id is canonical). Two users — or - * even the same user — can own meshes with identical slugs without - * colliding at the broker layer. The only collision risk is *local*: - * the CLI's config keys on slug, so two joined meshes with the same - * slug make the picker ambiguous. We refuse the rename in that case - * and point the user at the conflict. - * - * Other peers connected to this mesh keep using the old slug in their - * local configs until they run `claudemesh sync`. The broker doesn't - * care — it routes by mesh.id internally. - */ - -import { reslug as reslugMesh } from "~/services/mesh/facade.js"; -import { getStoredToken } from "~/services/auth/facade.js"; -import { ApiError } from "~/services/api/facade.js"; -import { readConfig, setMeshConfig, removeMeshConfig } from "~/services/config/facade.js"; -import { bold, dim, green, icons } from "~/ui/styles.js"; -import { EXIT } from "~/constants/exit-codes.js"; - -const SLUG_RE = /^[a-z0-9][a-z0-9-]{1,31}$/; - -export async function slug(oldSlug: string, newSlug: string): Promise { - if (!oldSlug || !newSlug) { - console.error(` ${icons.cross} Usage: ${bold("claudemesh slug")} `); - return EXIT.INVALID_ARGS; - } - if (!SLUG_RE.test(newSlug)) { - console.error(` ${icons.cross} Invalid slug: must be 2-32 chars, lowercase alnum + hyphens, start with alnum`); - return EXIT.INVALID_ARGS; - } - if (oldSlug === newSlug) { - console.error(` ${icons.cross} Old and new slug are the same.`); - return EXIT.INVALID_ARGS; - } - - const auth = getStoredToken(); - if (!auth) { - console.error(` ${icons.cross} Renaming a mesh requires a claudemesh.com account session.`); - console.error(` ${dim("Run")} ${bold("claudemesh login")} ${dim("first.")}`); - return EXIT.AUTH_FAILED; - } - - // Local-collision check: refuse if another joined mesh already owns - // this slug locally. The broker would happily accept the change, but - // the CLI picker keys on slug — we'd render two indistinguishable - // entries. - const cfg = readConfig(); - const collision = cfg.meshes.find((m) => m.slug === newSlug && m.slug !== oldSlug); - if (collision) { - console.error(` ${icons.cross} Slug "${newSlug}" already used locally by another joined mesh.`); - console.error(` ${dim("Pick a different slug, or leave the other mesh first.")}`); - return EXIT.ALREADY_EXISTS; - } - - try { - const updated = await reslugMesh(oldSlug, newSlug); - // Reflect the new slug in local config so the picker / --mesh - // flag work without needing `claudemesh sync` afterwards. - const local = cfg.meshes.find((m) => m.slug === oldSlug); - if (local) { - removeMeshConfig(oldSlug); - setMeshConfig(updated.slug, { ...local, slug: updated.slug, name: updated.name }); - } - console.log(` ${green(icons.check)} Slug changed: "${oldSlug}" → "${updated.slug}"`); - console.log(` ${dim("Other peers will pick up the new slug after they run")} ${bold("claudemesh sync")}`); - return EXIT.SUCCESS; - } catch (err) { - if (err instanceof ApiError) { - const body = err.body as { error?: string } | undefined; - console.error(` ${icons.cross} ${body?.error ?? err.statusText}`); - if (err.status === 401) return EXIT.AUTH_FAILED; - if (err.status === 403) return EXIT.PERMISSION_DENIED; - if (err.status === 404) return EXIT.NOT_FOUND; - if (err.status === 400) return EXIT.INVALID_ARGS; - return EXIT.INTERNAL_ERROR; - } - console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`); - return EXIT.INTERNAL_ERROR; - } -} diff --git a/apps/cli/src/entrypoints/cli.ts b/apps/cli/src/entrypoints/cli.ts index 2d994e0..4dc6120 100644 --- a/apps/cli/src/entrypoints/cli.ts +++ b/apps/cli/src/entrypoints/cli.ts @@ -73,8 +73,7 @@ Mesh claudemesh launch [slug] launch Claude Code on a mesh (alias: connect) claudemesh list show your meshes (alias: ls) claudemesh delete [slug] delete a mesh (alias: rm) - claudemesh rename rename a mesh's display name (slug stays) - claudemesh slug change a mesh's slug (URL-safe identifier) + claudemesh rename change a mesh's slug (the identifier you see and type) claudemesh share [email] share mesh (invite link / send email) Peer (resource form, recommended) @@ -313,7 +312,6 @@ async function main(): Promise { case "list": case "ls": { const { runList } = await import("~/commands/list.js"); await runList(); break; } case "delete": case "rm": { const { deleteMesh } = await import("~/commands/delete-mesh.js"); process.exit(await deleteMesh(positionals[0] ?? "", { yes: !!flags.y || !!flags.yes })); break; } case "rename": { const { rename } = await import("~/commands/rename.js"); process.exit(await rename(positionals[0] ?? "", positionals[1] ?? "")); break; } - case "slug": { const { slug } = await import("~/commands/slug.js"); process.exit(await slug(positionals[0] ?? "", positionals[1] ?? "")); break; } case "share": case "invite": { const { invite } = await import("~/commands/invite.js"); process.exit(await invite(positionals[0], { mesh: flags.mesh as string, json: !!flags.json })); break; } case "disconnect": { const { runDisconnect } = await import("~/commands/kick.js"); process.exit(await runDisconnect(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; } case "kick": { const { runKick } = await import("~/commands/kick.js"); process.exit(await runKick(positionals[0], { mesh: flags.mesh as string, stale: flags.stale as string, all: !!flags.all })); break; } diff --git a/apps/cli/src/services/api/my.ts b/apps/cli/src/services/api/my.ts index 6b17139..6ec82c2 100644 --- a/apps/cli/src/services/api/my.ts +++ b/apps/cli/src/services/api/my.ts @@ -19,20 +19,13 @@ export async function createMesh( return post<{ id: string; slug: string; name: string }>("/api/my/meshes", body, token); } -export async function renameMesh(token: string, slug: string, newName: string) { +export async function renameMesh(token: string, oldSlug: string, newSlug: string) { // Routed through /api/cli/* (not /api/my/*) because the CLI JWT // can't authenticate against the better-auth-protected myRouter. // The /api/cli/meshes/:slug route validates the JWT inline. - return request<{ slug: string; name: string }>({ - path: `/api/cli/meshes/${slug}`, - method: "PATCH", - body: { name: newName }, - token, - }); -} - -export async function reslugMesh(token: string, oldSlug: string, newSlug: string) { - return request<{ slug: string; name: string }>({ + // v0.7.0 collapse: rename = change slug. mesh.name kept in sync + // server-side (column stays for now, value mirrors slug). + return request<{ slug: string }>({ path: `/api/cli/meshes/${oldSlug}`, method: "PATCH", body: { slug: newSlug }, diff --git a/apps/cli/src/services/mesh/facade.ts b/apps/cli/src/services/mesh/facade.ts index 6e9034e..d584600 100644 --- a/apps/cli/src/services/mesh/facade.ts +++ b/apps/cli/src/services/mesh/facade.ts @@ -1,7 +1,6 @@ export { listMeshes as list } from "./list.js"; export { createMesh as create } from "./create.js"; -export { renameMesh as rename } from "./rename.js"; -export { reslugMesh as reslug } from "./reslug.js"; +export { renameMesh as rename, renameMesh as reslug } from "./rename.js"; export { leaveMesh as leave } from "./leave.js"; export { joinMesh as join, joinMesh } from "./join.js"; export { resolveTarget } from "./resolve-target.js"; diff --git a/apps/cli/src/services/mesh/rename.ts b/apps/cli/src/services/mesh/rename.ts index 9b7cd0f..11b0205 100644 --- a/apps/cli/src/services/mesh/rename.ts +++ b/apps/cli/src/services/mesh/rename.ts @@ -1,8 +1,8 @@ import { my } from "~/services/api/facade.js"; import { getStoredToken } from "~/services/auth/facade.js"; -export async function renameMesh(slug: string, newName: string): Promise { +export async function renameMesh(oldSlug: string, newSlug: string): Promise<{ slug: string }> { const auth = getStoredToken(); if (!auth) throw new Error("Not signed in"); - await my.renameMesh(auth.session_token, slug, newName); + return await my.renameMesh(auth.session_token, oldSlug, newSlug); } diff --git a/apps/cli/src/services/mesh/reslug.ts b/apps/cli/src/services/mesh/reslug.ts deleted file mode 100644 index 6a41718..0000000 --- a/apps/cli/src/services/mesh/reslug.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { my } from "~/services/api/facade.js"; -import { getStoredToken } from "~/services/auth/facade.js"; - -export async function reslugMesh(oldSlug: string, newSlug: string): Promise<{ slug: string; name: string }> { - const auth = getStoredToken(); - if (!auth) throw new Error("Not signed in"); - return await my.reslugMesh(auth.session_token, oldSlug, newSlug); -} diff --git a/apps/cli/src/ui/welcome/MeshPickerStep.ts b/apps/cli/src/ui/welcome/MeshPickerStep.ts index 6867986..38a533e 100644 --- a/apps/cli/src/ui/welcome/MeshPickerStep.ts +++ b/apps/cli/src/ui/welcome/MeshPickerStep.ts @@ -1,10 +1,10 @@ import type { JoinedMesh } from "~/services/config/facade.js"; -import { bold, dim } from "../styles.js"; +import { bold } from "../styles.js"; export function renderMeshPicker(meshes: JoinedMesh[]): void { console.log("\n Select a mesh:\n"); meshes.forEach((m, i) => { - console.log(" " + bold((i + 1) + ")") + " " + m.slug + " " + dim("(" + m.name + ")")); + console.log(" " + bold((i + 1) + ")") + " " + m.slug); }); console.log(""); } diff --git a/apps/web/src/app/api/cli/meshes/[slug]/route.ts b/apps/web/src/app/api/cli/meshes/[slug]/route.ts index fb526b8..695850e 100644 --- a/apps/web/src/app/api/cli/meshes/[slug]/route.ts +++ b/apps/web/src/app/api/cli/meshes/[slug]/route.ts @@ -79,20 +79,15 @@ export async function PATCH( } const { slug } = await params; - let body: { name?: string; slug?: string }; + let body: { slug?: string }; try { - body = (await request.json()) as { name?: string; slug?: string }; + body = (await request.json()) as { slug?: string }; } catch { return NextResponse.json({ error: "invalid JSON body" }, { status: 400 }); } - const newName = body.name?.trim(); const newSlug = body.slug?.trim(); - - if (!newName && !newSlug) { - return NextResponse.json({ error: "name or slug is required" }, { status: 400 }); - } - if (newName !== undefined && newName.length > 80) { - return NextResponse.json({ error: "name too long (max 80 chars)" }, { status: 400 }); + if (!newSlug) { + return NextResponse.json({ error: "slug is required" }, { status: 400 }); } // Slug regex matches the CLI's pre-flight check. Lowercase only, // must start with alnum, may contain hyphens, 2-32 chars total. @@ -100,7 +95,7 @@ export async function PATCH( // comment on mesh.slug — so we don't enforce a uniqueness collision // here. Local CLI configs key on slug, so the picker collides // locally; that's the user's call. - if (newSlug !== undefined && !/^[a-z0-9][a-z0-9-]{1,31}$/.test(newSlug)) { + if (!/^[a-z0-9][a-z0-9-]{1,31}$/.test(newSlug)) { return NextResponse.json( { error: "slug must be 2-32 chars, lowercase alnum + hyphens, start with alnum" }, { status: 400 }, @@ -132,15 +127,14 @@ export async function PATCH( ); } - const patch: { name?: string; slug?: string } = {}; - if (newName !== undefined) patch.name = newName; - if (newSlug !== undefined) patch.slug = newSlug; - + // Soft-collapse: name and slug are a single concept user-facing, + // so we always sync name = slug. mesh.name column stays for now + // (avoids touching ~25 reader sites); a future migration drops it. const [updated] = await db .update(mesh) - .set(patch) + .set({ slug: newSlug, name: newSlug }) .where(eq(mesh.slug, slug)) - .returning({ slug: mesh.slug, name: mesh.name }); + .returning({ slug: mesh.slug }); return NextResponse.json(updated); } diff --git a/docs/roadmap.md b/docs/roadmap.md index f1586d1..8999168 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -298,6 +298,23 @@ 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.7.0 — collapse `mesh.name` and `mesh.slug` into one identifier** — + pre-launch correction of a piece of generic SaaS scaffolding that + was earning no keep here. Every visible surface (CLI picker, + `--mesh` flag, dashboard sidebar, broker presence rows) already + keyed on slug; `name` was a parallel string that confused users + on rename ("I renamed it but nothing visible changed"). Now: slug + IS the identifier. `claudemesh rename ` is + the entire rename surface — there is no separate display name. + CLI picker drops the `(parens)`. Server `PATCH /api/cli/meshes/:slug` + body becomes `{ slug }`; the route writes both columns to keep + them in sync. New mesh creation derives slug from input.name and + stores `name = slug`. The `mesh.name` DB column is kept for now + (avoids touching ~25 reader sites in queries.ts / v1-router.ts / + dashboard pages) and always equals slug; a follow-up migration + drops it. The just-shipped `claudemesh slug` verb (v0.6.2) is + removed — its semantics merge into `rename`. *Shipped 2026-05-03 + in CLI v1.21.0 + web.* - **v0.6.2 — `claudemesh slug `** — change a mesh's URL-safe slug (the identifier the CLI picker, `--mesh` flag, and dashboard sidebar all key on). Slugs are NOT globally diff --git a/packages/api/src/modules/mesh/mutations.ts b/packages/api/src/modules/mesh/mutations.ts index f748f30..34b6b4f 100644 --- a/packages/api/src/modules/mesh/mutations.ts +++ b/packages/api/src/modules/mesh/mutations.ts @@ -136,10 +136,13 @@ export const createMyMesh = async ({ s.base64_variants.URLSAFE_NO_PADDING, ); + // v0.7.0 collapse: mesh.name always == mesh.slug. Input.name is + // accepted from create UIs (dashboard, CLI) and used to derive the + // slug; we drop the original spelling so name and slug never drift. const [created] = await db .insert(mesh) .values({ - name: input.name, + name: slug, slug, visibility: input.visibility, transport: input.transport,