4 Commits

Author SHA1 Message Date
Alejandro Gutiérrez
8810aa1e9e feat(cli): --version, status, doctor commands (v0.1.3)
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
Three Tier-2 polish commands for debugging + discoverability:

- claudemesh --version / -v: print CLI version (baked from
  package.json at build time via Bun JSON import).
- claudemesh status: WS-probe each joined mesh's broker, report
  reachability per mesh. Exit 1 if any broker unreachable.
- claudemesh doctor: run 6 preconditions — Node>=20, claude on PATH,
  MCP registered, hooks registered, config file parses + chmod 0600,
  mesh keypairs validate. Each check has a pass/fail + fix hint.
  Exit 0 if all pass.

Help text now leads with version (\"claudemesh v0.1.3 —\").

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 23:01:52 +01:00
Alejandro Gutiérrez
fa234fae25 feat(web): announce claudemesh-cli v0.1.2 in news toaster
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
Additive NEWS entry pointing to the new public repo
github.com/alezmad/claudemesh-cli and the launch command.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:29:21 +01:00
Alejandro Gutiérrez
7ab3c8d465 feat(cli): claudemesh launch command with transparency banner (v0.1.2)
Adds `claudemesh launch [args]` that spawns Claude Code with
--dangerously-load-development-channels server:claudemesh so peer
messages arrive as <channel> system reminders mid-turn instead of
pull-only via check_messages. Windows uses shell:true to resolve
claude.cmd from PATHEXT.

Prints an info banner before spawning that explains the channel's
scope (peer text injection only), the trust model (treat as
untrusted input), and that existing tool-approval prompts remain
the safety net. --quiet skips the banner.

Install output now mentions `claudemesh launch` as the recommended
launch path; plain `claude` still works for pull-only mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:22:46 +01:00
Alejandro Gutiérrez
f144e0485a fix(cli): no base64 fallback on direct-message decrypt failure
The push handler previously fell through to base64-decoding the
raw ciphertext whenever decryptDirect() returned null. For direct
(crypto_box) messages that produces garbage binary which surfaces
as garbled bytes in Claude's <channel> reminder. Limit the base64
fallback to legacy broadcast/channel messages (no senderPubkey),
and emit a clearer "⚠ message from <pubkey> failed to decrypt"
warning when direct decryption fails.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-05 22:22:33 +01:00
11 changed files with 501 additions and 10 deletions

View File

@@ -28,6 +28,28 @@ Run the printed command, then restart Claude Code.
claudemesh join https://claudemesh.com/join/<token> claudemesh join https://claudemesh.com/join/<token>
``` ```
## Launch Claude Code
For real-time **push messages** from peers (messages injected mid-turn
as `<channel source="claudemesh">` system reminders), launch with:
```sh
claudemesh launch
# or pass through any claude flags:
claudemesh launch --model opus
claudemesh launch --resume
```
Under the hood this runs:
```sh
claude --dangerously-load-development-channels server:claudemesh
```
Plain `claude` still works — the MCP tools are available — but incoming
messages are **pull-only** via the `check_messages` tool instead of
being pushed to Claude immediately.
The invite link is generated by whoever runs the mesh. It bundles the The invite link is generated by whoever runs the mesh. It bundles the
mesh id, expiry, signing key, and role. Your CLI verifies it, mesh id, expiry, signing key, and role. Your CLI verifies it,
generates a fresh keypair, enrolls you with the broker, and persists generates a fresh keypair, enrolls you with the broker, and persists
@@ -36,7 +58,9 @@ the result to `~/.claudemesh/config.json`.
## Commands ## Commands
```sh ```sh
claudemesh install # print MCP registration command claudemesh install # register MCP + status hooks
claudemesh uninstall # remove MCP + status hooks
claudemesh launch [args] # launch Claude Code with push messages enabled
claudemesh join <url> # join a mesh via invite URL claudemesh join <url> # join a mesh via invite URL
claudemesh list # show joined meshes + identities claudemesh list # show joined meshes + identities
claudemesh leave <slug> # leave a mesh claudemesh leave <slug> # leave a mesh

View File

@@ -1,6 +1,6 @@
{ {
"name": "claudemesh-cli", "name": "claudemesh-cli",
"version": "0.1.1", "version": "0.1.3",
"description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.", "description": "Claude Code MCP client for claudemesh — peer mesh messaging between Claude sessions.",
"keywords": [ "keywords": [
"claude-code", "claude-code",

View File

@@ -0,0 +1,212 @@
/**
* `claudemesh doctor` — diagnostic checks.
*
* Walks through the install + runtime preconditions and prints each
* as pass/fail with a fix hint on failure. Exit 0 if everything
* passes, 1 otherwise.
*/
import { existsSync, readFileSync, statSync } from "node:fs";
import { homedir, platform } from "node:os";
import { join } from "node:path";
import { spawnSync } from "node:child_process";
import { loadConfig, getConfigPath } from "../state/config";
import { VERSION } from "../version";
interface Check {
name: string;
pass: boolean;
detail?: string;
fix?: string;
}
function checkNode(): Check {
const major = Number(process.versions.node.split(".")[0]);
return {
name: "Node.js >= 20",
pass: major >= 20,
detail: `v${process.versions.node}`,
fix: "Install Node 20 or newer (https://nodejs.org)",
};
}
function checkClaudeOnPath(): Check {
const res =
platform() === "win32"
? spawnSync("where", ["claude"])
: spawnSync("sh", ["-c", "command -v claude"]);
const onPath = res.status === 0;
const location = onPath ? res.stdout.toString().trim().split("\n")[0] : undefined;
return {
name: "claude binary on PATH",
pass: onPath,
detail: location,
fix: "Install Claude Code (https://claude.com/claude-code)",
};
}
function checkMcpRegistered(): Check {
const claudeConfig = join(homedir(), ".claude.json");
if (!existsSync(claudeConfig)) {
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: false,
fix: "Run `claudemesh install`",
};
}
try {
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
mcpServers?: Record<string, unknown>;
};
const registered = Boolean(cfg.mcpServers?.["claudemesh"]);
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: registered,
fix: registered ? undefined : "Run `claudemesh install`",
};
} catch (e) {
return {
name: "claudemesh MCP registered in ~/.claude.json",
pass: false,
detail: e instanceof Error ? e.message : String(e),
fix: "Check ~/.claude.json for JSON parse errors",
};
}
}
function checkHooksRegistered(): Check {
const settings = join(homedir(), ".claude", "settings.json");
if (!existsSync(settings)) {
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: false,
fix: "Run `claudemesh install` (remove --no-hooks)",
};
}
try {
const raw = readFileSync(settings, "utf-8");
const has = raw.includes("claudemesh hook ");
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: has,
fix: has ? undefined : "Run `claudemesh install` (remove --no-hooks)",
};
} catch (e) {
return {
name: "Status hooks registered in ~/.claude/settings.json",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
function checkConfigFile(): Check {
const path = getConfigPath();
if (!existsSync(path)) {
return {
name: "~/.claudemesh/config.json exists and parses",
pass: true,
detail: "not created yet (fine — no meshes joined)",
};
}
try {
loadConfig();
const st = statSync(path);
const mode = (st.mode & 0o777).toString(8);
const secure = platform() === "win32" || mode === "600";
return {
name: "~/.claudemesh/config.json parses + chmod 0600",
pass: secure,
detail: platform() === "win32" ? "chmod skipped on Windows" : `0${mode}`,
fix: secure ? undefined : `chmod 600 ${path}`,
};
} catch (e) {
return {
name: "~/.claudemesh/config.json exists and parses",
pass: false,
detail: e instanceof Error ? e.message : String(e),
fix: "Inspect or delete ~/.claudemesh/config.json and re-join",
};
}
}
function checkKeypairs(): Check {
try {
const cfg = loadConfig();
if (cfg.meshes.length === 0) {
return {
name: "Mesh keypairs valid",
pass: true,
detail: "no meshes joined",
};
}
for (const m of cfg.meshes) {
if (m.pubkey.length !== 64 || !/^[0-9a-f]+$/.test(m.pubkey)) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: `${m.slug}: pubkey malformed`,
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
};
}
if (m.secretKey.length !== 128 || !/^[0-9a-f]+$/.test(m.secretKey)) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: `${m.slug}: secret key malformed`,
fix: `Leave + re-join the mesh: claudemesh leave ${m.slug}`,
};
}
}
return {
name: "Mesh keypairs valid",
pass: true,
detail: `${cfg.meshes.length} mesh(es)`,
};
} catch (e) {
return {
name: "Mesh keypairs valid",
pass: false,
detail: e instanceof Error ? e.message : String(e),
};
}
}
export async function runDoctor(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s);
console.log(`claudemesh doctor (v${VERSION})`);
console.log("─".repeat(60));
const checks: Check[] = [
checkNode(),
checkClaudeOnPath(),
checkMcpRegistered(),
checkHooksRegistered(),
checkConfigFile(),
checkKeypairs(),
];
for (const c of checks) {
const mark = c.pass ? green("✓") : red("✗");
const detail = c.detail ? dim(` (${c.detail})`) : "";
console.log(`${mark} ${c.name}${detail}`);
if (!c.pass && c.fix) {
console.log(dim(`${c.fix}`));
}
}
const failing = checks.filter((c) => !c.pass);
console.log("");
if (failing.length === 0) {
console.log(green("All checks passed."));
process.exit(0);
} else {
console.log(red(`${failing.length} check(s) failed.`));
process.exit(1);
}
}

View File

@@ -307,6 +307,17 @@ export function runInstall(args: string[] = []): void {
console.log( console.log(
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`, `Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
); );
console.log("");
console.log(
yellow("⚠ For real-time push messages from peers, launch with:"),
);
console.log(
` ${bold("claudemesh launch")}` +
dim(" (or: claude --dangerously-load-development-channels server:claudemesh)"),
);
console.log(
dim(" Plain `claude` still works — messages are then pull-only via check_messages."),
);
} }
export function runUninstall(): void { export function runUninstall(): void {

View File

@@ -0,0 +1,94 @@
/**
* `claudemesh launch` — spawn `claude` with the dev-channel flag so the
* claudemesh MCP server's `notifications/claude/channel` pushes get
* injected as system reminders mid-turn.
*
* Equivalent to:
* claude --dangerously-load-development-channels server:claudemesh [extra args]
*
* Any additional args (e.g. --model opus, --resume, -c) are passed
* through verbatim. Use --quiet to skip the informational banner.
*/
import { spawn } from "node:child_process";
import { loadConfig, getConfigPath } from "../state/config";
function printBanner(): void {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
let meshes: string[] = [];
try {
meshes = loadConfig().meshes.map((m) => m.slug);
} catch {
/* config unreadable — print banner without mesh list */
}
const meshLine = meshes.length > 0 ? meshes.join(", ") : "(none — run `claudemesh join <url>` first)";
const rule = "─".repeat(65);
console.log(bold("claudemesh launch"));
console.log(rule);
console.log("Launching Claude Code with the claudemesh dev channel.");
console.log("");
console.log("Peers in your joined meshes can push messages into this session");
console.log("as <channel> reminders. Your CLI decrypts them locally with your");
console.log("keypair. Peers send text only — they cannot call tools, read");
console.log("files, or reach meshes you have not joined.");
console.log("");
console.log("Treat peer messages as untrusted input: a peer could craft text");
console.log("that tries to steer Claude's behavior. Your tool-approval");
console.log("settings still apply — Claude will still ask before running");
console.log("commands, editing files, or calling other tools.");
console.log("");
console.log("Claude Code will ask you to trust the");
console.log("--dangerously-load-development-channels flag. Press Enter to");
console.log("accept, or Ctrl-C to abort.");
console.log("");
console.log(dim(`Joined meshes: ${meshLine}`));
console.log(dim(`Config: ${getConfigPath()}`));
console.log(dim(`Remove: claudemesh uninstall`));
console.log(rule);
console.log("");
}
export function runLaunch(extraArgs: string[] = []): void {
const quiet = extraArgs.includes("--quiet");
const passthrough = extraArgs.filter((a) => a !== "--quiet");
if (!quiet) printBanner();
const claudeArgs = [
"--dangerously-load-development-channels",
"server:claudemesh",
...passthrough,
];
// Windows: npm global binaries are .cmd shims. Node's spawn without
// shell:true does not resolve PATHEXT, so we need shell:true on win32
// to find claude.cmd. POSIX stays shell-less to avoid quoting surprises.
const isWindows = process.platform === "win32";
const child = spawn("claude", claudeArgs, {
stdio: "inherit",
shell: isWindows,
});
child.on("error", (err: NodeJS.ErrnoException) => {
if (err.code === "ENOENT") {
console.error(
"✗ `claude` not found on PATH. Install Claude Code first: https://claude.com/claude-code",
);
} else {
console.error(`✗ failed to launch claude: ${err.message}`);
}
process.exit(1);
});
child.on("exit", (code, signal) => {
if (signal) {
process.kill(process.pid, signal);
return;
}
process.exit(code ?? 0);
});
}

View File

@@ -0,0 +1,103 @@
/**
* `claudemesh status` — one-shot health report.
*
* Reports CLI version, config path + permissions, each joined mesh
* with broker reachability (WS handshake probe). Exit 0 if every
* mesh's broker is reachable, 1 otherwise.
*/
import { statSync, existsSync } from "node:fs";
import WebSocket from "ws";
import { loadConfig, getConfigPath } from "../state/config";
import { VERSION } from "../version";
interface MeshStatus {
slug: string;
brokerUrl: string;
pubkey: string;
reachable: boolean;
error?: string;
}
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> {
return new Promise((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => {
try { ws.terminate(); } catch { /* noop */ }
resolve({ ok: false, error: "timeout" });
}, timeoutMs);
ws.on("open", () => {
clearTimeout(timer);
try { ws.close(); } catch { /* noop */ }
resolve({ ok: true });
});
ws.on("error", (err) => {
clearTimeout(timer);
resolve({ ok: false, error: err.message });
});
});
}
export async function runStatus(): Promise<void> {
const useColor =
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
const dim = (s: string): string => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
const green = (s: string): string => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
const red = (s: string): string => (useColor ? `\x1b[31m${s}\x1b[39m` : s);
console.log(`claudemesh status (v${VERSION})`);
console.log("─".repeat(60));
const configPath = getConfigPath();
let configPerms = "missing";
if (existsSync(configPath)) {
const st = statSync(configPath);
const mode = (st.mode & 0o777).toString(8).padStart(4, "0");
configPerms = mode === "0600" ? `${mode}` : `${mode} ⚠ (expected 0600)`;
}
console.log(`Config: ${configPath} (${configPerms})`);
const config = loadConfig();
if (config.meshes.length === 0) {
console.log("");
console.log(dim("No meshes joined. Run `claudemesh join <invite-url>` to get started."));
process.exit(0);
}
console.log("");
console.log(`Meshes (${config.meshes.length}):`);
const results: MeshStatus[] = [];
for (const m of config.meshes) {
process.stdout.write(` ${m.slug.padEnd(20)} probing ${m.brokerUrl}`);
const probe = await probeBroker(m.brokerUrl);
results.push({
slug: m.slug,
brokerUrl: m.brokerUrl,
pubkey: m.pubkey,
reachable: probe.ok,
error: probe.error,
});
if (probe.ok) {
console.log(green("reachable"));
} else {
console.log(red(`unreachable (${probe.error})`));
}
}
console.log("");
for (const r of results) {
console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}`));
}
const allOk = results.every((r) => r.reachable);
console.log("");
if (allOk) {
console.log(green("All meshes reachable."));
process.exit(0);
} else {
const broken = results.filter((r) => !r.reachable).length;
console.log(red(`${broken} of ${results.length} mesh(es) unreachable.`));
process.exit(1);
}
}

View File

@@ -15,8 +15,12 @@ import { runList } from "./commands/list";
import { runLeave } from "./commands/leave"; import { runLeave } from "./commands/leave";
import { runSeedTestMesh } from "./commands/seed-test-mesh"; import { runSeedTestMesh } from "./commands/seed-test-mesh";
import { runHook } from "./commands/hook"; import { runHook } from "./commands/hook";
import { runLaunch } from "./commands/launch";
import { runStatus } from "./commands/status";
import { runDoctor } from "./commands/doctor";
import { VERSION } from "./version";
const HELP = `claudemesh — peer mesh for Claude Code sessions const HELP = `claudemesh v${VERSION} — peer mesh for Claude Code sessions
Usage: Usage:
claudemesh <command> [args] claudemesh <command> [args]
@@ -25,12 +29,18 @@ Commands:
install Register MCP + Stop/UserPromptSubmit status hooks install Register MCP + Stop/UserPromptSubmit status hooks
(add --no-hooks for bare MCP registration) (add --no-hooks for bare MCP registration)
uninstall Remove MCP server + hooks uninstall Remove MCP server + hooks
launch [args] Launch Claude Code with real-time push messages enabled
(add --quiet to skip the info banner; passes through
extra flags, e.g. --model, --resume)
join <url> Join a mesh via https://claudemesh.com/join/... URL join <url> Join a mesh via https://claudemesh.com/join/... URL
list Show all joined meshes list Show all joined meshes
leave <slug> Leave a joined mesh leave <slug> Leave a joined mesh
status Health report: broker reachability per joined mesh
doctor Diagnostic checks (install, config, keypairs, PATH)
seed-test-mesh Dev-only: inject a mesh into config (skips invite flow) seed-test-mesh Dev-only: inject a mesh into config (skips invite flow)
mcp Start MCP server (stdio) — invoked by Claude Code mcp Start MCP server (stdio) — invoked by Claude Code
--help, -h Show this help --help, -h Show this help
--version, -v Show the CLI version
Environment: Environment:
CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws) CLAUDEMESH_BROKER_URL Override broker URL (default: wss://ic.claudemesh.com/ws)
@@ -55,6 +65,9 @@ async function main(): Promise<void> {
case "hook": case "hook":
await runHook(args); await runHook(args);
return; return;
case "launch":
runLaunch(args);
return;
case "join": case "join":
await runJoin(args); await runJoin(args);
return; return;
@@ -64,9 +77,20 @@ async function main(): Promise<void> {
case "leave": case "leave":
runLeave(args); runLeave(args);
return; return;
case "status":
await runStatus();
return;
case "doctor":
await runDoctor();
return;
case "seed-test-mesh": case "seed-test-mesh":
runSeedTestMesh(args); runSeedTestMesh(args);
return; return;
case "--version":
case "-v":
case "version":
console.log(VERSION);
return;
case "--help": case "--help":
case "-h": case "-h":
case "help": case "help":

View File

@@ -73,8 +73,13 @@ function resolveClient(to: string): {
}; };
} }
function decryptFailedWarning(senderPubkey: string): string {
const who = senderPubkey ? senderPubkey.slice(0, 12) + "…" : "unknown sender";
return `⚠ message from ${who} failed to decrypt (tampered or wrong keypair)`;
}
function formatPush(p: InboundPush, meshSlug: string): string { function formatPush(p: InboundPush, meshSlug: string): string {
const body = p.plaintext ?? "(decryption failed)"; const body = p.plaintext ?? decryptFailedWarning(p.senderPubkey);
return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`; return `[${meshSlug}] from ${p.senderPubkey.slice(0, 12)}… (${p.priority}, ${p.createdAt}):\n${body}`;
} }
@@ -82,7 +87,7 @@ export async function startMcpServer(): Promise<void> {
const config = loadConfig(); const config = loadConfig();
const server = new Server( const server = new Server(
{ name: "claudemesh", version: "0.1.1" }, { name: "claudemesh", version: "0.1.3" },
{ {
capabilities: { capabilities: {
experimental: { "claude/channel": {} }, experimental: { "claude/channel": {} },
@@ -215,7 +220,7 @@ If you have multiple joined meshes, prefix the \`to\` argument of send_message w
const fromName = fromPubkey const fromName = fromPubkey
? `peer-${fromPubkey.slice(0, 8)}` ? `peer-${fromPubkey.slice(0, 8)}`
: "unknown"; : "unknown";
const content = msg.plaintext ?? "(decryption failed)"; const content = msg.plaintext ?? decryptFailedWarning(fromPubkey);
try { try {
await server.notification({ await server.notification({
method: "notifications/claude/channel", method: "notifications/claude/channel",

8
apps/cli/src/version.ts Normal file
View File

@@ -0,0 +1,8 @@
/**
* Bundled version string. Bun inlines the package.json JSON at build
* time, so the shipped binary carries the exact version that was
* published.
*/
import pkg from "../package.json" with { type: "json" };
export const VERSION: string = pkg.version;

View File

@@ -312,10 +312,14 @@ export class BrokerClient {
this.mesh.secretKey, this.mesh.secretKey,
); );
} }
// If decryption failed, fall back to base64 UTF-8 unwrap — // Legacy/broadcast path: no senderPubkey means the message
// this covers the legacy plaintext path for broadcasts/channels // was not crypto_box'd, so base64 UTF-8 unwrap is correct.
// until channel crypto lands. // For direct messages (senderPubkey present) we MUST NOT
if (plaintext === null && ciphertext) { // base64-decode the ciphertext on decrypt failure — that
// produces garbage binary that surfaces as garbled bytes
// to Claude. Leave plaintext=null and let consumers emit
// a clear "failed to decrypt" warning.
if (plaintext === null && ciphertext && !senderPubkey) {
try { try {
plaintext = Buffer.from(ciphertext, "base64").toString("utf-8"); plaintext = Buffer.from(ciphertext, "base64").toString("utf-8");
} catch { } catch {

View File

@@ -3,6 +3,12 @@ import { useState } from "react";
import Link from "next/link"; import Link from "next/link";
const NEWS = [ const NEWS = [
{
tag: "New",
title: "claudemesh launch (v0.1.2)",
body: "Real-time peer messages pushed into Claude Code mid-turn. One command. Source open at github.com/alezmad/claudemesh-cli.",
href: "https://github.com/alezmad/claudemesh-cli",
},
{ {
tag: "Beta", tag: "Beta",
title: "Mesh Dashboard", title: "Mesh Dashboard",