feat: claudemesh slug <old> <new> — change a mesh's slug (v1.20.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

Slugs are not globally unique (mesh.id is canonical) so the route
only validates the regex and updates the row. CLI refuses a local
collision (two joined meshes sharing a slug would make the picker
ambiguous) and rewrites ~/.claudemesh/config.json on success.
Other peers pick up the new slug on next claudemesh sync.

Server: PATCH /api/cli/meshes/:slug body now accepts { name?, slug? }
— same route, just optional both fields.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alejandro Gutiérrez
2026-05-03 15:08:32 +01:00
parent 5a1d5d6a49
commit e84914b25b
8 changed files with 142 additions and 9 deletions

View File

@@ -0,0 +1,83 @@
/**
* `claudemesh slug <old-slug> <new-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<number> {
if (!oldSlug || !newSlug) {
console.error(` ${icons.cross} Usage: ${bold("claudemesh slug")} <old-slug> <new-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;
}
}

View File

@@ -73,7 +73,8 @@ 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 <slug> <name> rename a mesh
claudemesh rename <slug> <name> rename a mesh's display name (slug stays)
claudemesh slug <old> <new> change a mesh's slug (URL-safe identifier)
claudemesh share [email] share mesh (invite link / send email)
Peer (resource form, recommended)
@@ -312,6 +313,7 @@ async function main(): Promise<void> {
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; }

View File

@@ -31,6 +31,15 @@ export async function renameMesh(token: string, slug: string, newName: string) {
});
}
export async function reslugMesh(token: string, oldSlug: string, newSlug: string) {
return request<{ slug: string; name: string }>({
path: `/api/cli/meshes/${oldSlug}`,
method: "PATCH",
body: { slug: newSlug },
token,
});
}
export async function createInvite(
token: string,
meshSlug: string,

View File

@@ -1,6 +1,7 @@
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 { leaveMesh as leave } from "./leave.js";
export { joinMesh as join, joinMesh } from "./join.js";
export { resolveTarget } from "./resolve-target.js";

View File

@@ -0,0 +1,8 @@
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);
}