refactor: rename cli-v2 → cli, archive legacy cli, plus broker-side grants + auto-migrate
- apps/cli/ is now the canonical CLI (was apps/cli-v2/). - apps/cli/ legacy v0 archived as branch 'legacy-cli-archive' and tag 'cli-v0-legacy-final' before deletion; git history preserves it too. - .github/workflows/release-cli.yml paths updated. - pnpm-lock.yaml regenerated. Broker-side peer-grant enforcement (spec: 2026-04-15-per-peer-capabilities): - 0020_peer-grants.sql adds peer_grants jsonb + GIN index on mesh.member. - handleSend in broker fetches recipient grant maps once per send, drops messages silently when sender lacks the required capability. - POST /cli/mesh/:slug/grants to update from CLI; broker_messages_dropped_by_grant_total metric. - CLI grant/revoke/block now mirror to broker via syncToBroker. Auto-migrate on broker startup: - apps/broker/src/migrate.ts runs drizzle migrate with pg_advisory_lock before the HTTP server binds. Exits non-zero on failure so Coolify healthcheck fails closed. - Dockerfile copies packages/db/migrations into /app/migrations. - postgres 3.4.5 added as direct broker dep. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
147
apps/cli/src/commands/backup.ts
Normal file
147
apps/cli/src/commands/backup.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
/**
|
||||
* `claudemesh backup` — encrypt the local config and save a portable
|
||||
* recovery file. Restore later with `claudemesh restore <file>` on any
|
||||
* machine to recover mesh memberships.
|
||||
*
|
||||
* Crypto:
|
||||
* - Argon2id KDF over a user passphrase → 32-byte key
|
||||
* (via libsodium's crypto_pwhash, INTERACTIVE limits so a weak
|
||||
* passphrase is still workable but brute-force remains expensive)
|
||||
* - XChaCha20-Poly1305 authenticated encryption of the JSON config
|
||||
* - Format: magic "CMB1" · salt (16B) · nonce (24B) · ciphertext
|
||||
*
|
||||
* Output: a single `.claudemesh-backup` file the user can store in
|
||||
* 1Password, email to themselves, etc. Zero server involvement.
|
||||
*
|
||||
* Passphrase hygiene: read twice from TTY, never echoed. Rejects
|
||||
* passphrases shorter than 12 characters.
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { createInterface } from "node:readline";
|
||||
import { getConfigPath } from "~/services/config/facade.js";
|
||||
import { ensureSodium } from "~/services/crypto/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const MAGIC = Buffer.from("CMB1", "utf-8");
|
||||
|
||||
function readHidden(prompt: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
process.stdout.write(prompt);
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout, terminal: true });
|
||||
// Node readline doesn't mask by default. Turn off echo manually.
|
||||
const stdin = process.stdin as NodeJS.ReadStream & { isTTY?: boolean };
|
||||
const wasRaw = Boolean(stdin.isRaw);
|
||||
if (stdin.isTTY) {
|
||||
stdin.setRawMode(true);
|
||||
}
|
||||
let buf = "";
|
||||
const onData = (chunk: Buffer): void => {
|
||||
const ch = chunk.toString("utf-8");
|
||||
if (ch === "\n" || ch === "\r" || ch === "\u0004") {
|
||||
stdin.removeListener("data", onData);
|
||||
if (stdin.isTTY) stdin.setRawMode(wasRaw);
|
||||
process.stdout.write("\n");
|
||||
rl.close();
|
||||
resolve(buf);
|
||||
return;
|
||||
}
|
||||
if (ch === "\u0003") { // ctrl-c
|
||||
process.exit(130);
|
||||
}
|
||||
if (ch === "\u007f") { // backspace
|
||||
buf = buf.slice(0, -1);
|
||||
return;
|
||||
}
|
||||
buf += ch;
|
||||
};
|
||||
stdin.on("data", onData);
|
||||
});
|
||||
}
|
||||
|
||||
async function deriveKey(pass: string, salt: Buffer, s: Awaited<ReturnType<typeof ensureSodium>>): Promise<Uint8Array> {
|
||||
return s.crypto_pwhash(
|
||||
32,
|
||||
pass,
|
||||
salt,
|
||||
s.crypto_pwhash_OPSLIMIT_INTERACTIVE,
|
||||
s.crypto_pwhash_MEMLIMIT_INTERACTIVE,
|
||||
s.crypto_pwhash_ALG_ARGON2ID13,
|
||||
);
|
||||
}
|
||||
|
||||
export async function runBackup(outPath: string | undefined): Promise<number> {
|
||||
const configPath = getConfigPath();
|
||||
if (!existsSync(configPath)) {
|
||||
console.error(" No config found — nothing to back up. Join a mesh first.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const plaintext = readFileSync(configPath);
|
||||
|
||||
const pass = await readHidden(" Passphrase (min 12 chars): ");
|
||||
if (pass.length < 12) {
|
||||
console.error(" ✗ Passphrase too short.");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const confirm = await readHidden(" Confirm passphrase: ");
|
||||
if (confirm !== pass) {
|
||||
console.error(" ✗ Passphrases did not match.");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
const s = await ensureSodium();
|
||||
const salt = Buffer.from(s.randombytes_buf(16));
|
||||
const nonce = Buffer.from(s.randombytes_buf(24));
|
||||
const key = await deriveKey(pass, salt, s);
|
||||
const ciphertext = Buffer.from(
|
||||
s.crypto_aead_xchacha20poly1305_ietf_encrypt(plaintext, null, null, nonce, key),
|
||||
);
|
||||
const blob = Buffer.concat([MAGIC, salt, nonce, ciphertext]);
|
||||
|
||||
const file = outPath ?? `claudemesh-backup-${new Date().toISOString().replace(/[:.]/g, "-")}.cmb`;
|
||||
writeFileSync(file, blob, { mode: 0o600 });
|
||||
console.log(`\n ✓ Backup saved: ${file}`);
|
||||
console.log(` Size: ${blob.length} bytes. Guard the passphrase — there is no recovery.\n`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runRestore(inPath: string | undefined): Promise<number> {
|
||||
if (!inPath) {
|
||||
console.error(" Usage: claudemesh restore <backup-file>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
if (!existsSync(inPath)) {
|
||||
console.error(` ✗ File not found: ${inPath}`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const blob = readFileSync(inPath);
|
||||
if (blob.length < 4 + 16 + 24 + 17 || !blob.subarray(0, 4).equals(MAGIC)) {
|
||||
console.error(" ✗ Not a claudemesh backup file (bad magic).");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const salt = blob.subarray(4, 20);
|
||||
const nonce = blob.subarray(20, 44);
|
||||
const ciphertext = blob.subarray(44);
|
||||
|
||||
const pass = await readHidden(" Passphrase: ");
|
||||
const s = await ensureSodium();
|
||||
const key = await deriveKey(pass, Buffer.from(salt), s);
|
||||
let plaintext: Uint8Array;
|
||||
try {
|
||||
plaintext = s.crypto_aead_xchacha20poly1305_ietf_decrypt(null, ciphertext, null, nonce, key);
|
||||
} catch {
|
||||
console.error(" ✗ Decryption failed — wrong passphrase or tampered file.");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
const configPath = getConfigPath();
|
||||
if (existsSync(configPath)) {
|
||||
const backupOld = `${configPath}.before-restore.${Date.now()}`;
|
||||
writeFileSync(backupOld, readFileSync(configPath), { mode: 0o600 });
|
||||
console.log(` ↻ Existing config saved to ${backupOld}`);
|
||||
}
|
||||
writeFileSync(configPath, Buffer.from(plaintext), { mode: 0o600 });
|
||||
console.log(`\n ✓ Config restored to ${configPath}`);
|
||||
console.log(" Run `claudemesh list` to verify your meshes.\n");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
122
apps/cli/src/commands/completions.ts
Normal file
122
apps/cli/src/commands/completions.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
/**
|
||||
* `claudemesh completions <shell>` — emit a completion script for bash / zsh / fish.
|
||||
*
|
||||
* Users pipe it into their shell's completion system:
|
||||
* bash: claudemesh completions bash > /etc/bash_completion.d/claudemesh
|
||||
* zsh: claudemesh completions zsh > ~/.zfunc/_claudemesh (add $fpath)
|
||||
* fish: claudemesh completions fish > ~/.config/fish/completions/claudemesh.fish
|
||||
*/
|
||||
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const COMMANDS = [
|
||||
"create", "new", "join", "add", "launch", "connect", "disconnect",
|
||||
"list", "ls", "delete", "rm", "rename", "share", "invite",
|
||||
"peers", "send", "inbox", "state", "info",
|
||||
"remember", "recall", "remind", "profile", "status",
|
||||
"login", "register", "logout", "whoami",
|
||||
"install", "uninstall", "doctor", "sync",
|
||||
"completions", "verify", "url-handler",
|
||||
"help",
|
||||
];
|
||||
|
||||
const FLAGS = [
|
||||
"--help", "-h", "--version", "-V", "--json", "--yes", "-y",
|
||||
"--quiet", "-q", "--mesh", "--name", "--join", "--resume",
|
||||
];
|
||||
|
||||
function bash(): string {
|
||||
return `# claudemesh bash completion
|
||||
_claudemesh_complete() {
|
||||
local cur prev words cword
|
||||
_init_completion || return
|
||||
|
||||
local commands="${COMMANDS.join(" ")}"
|
||||
local flags="${FLAGS.join(" ")}"
|
||||
|
||||
if [[ \${cword} -eq 1 ]]; then
|
||||
COMPREPLY=( $(compgen -W "\${commands}" -- "\${cur}") )
|
||||
return 0
|
||||
fi
|
||||
|
||||
case "\${cur}" in
|
||||
-*)
|
||||
COMPREPLY=( $(compgen -W "\${flags}" -- "\${cur}") )
|
||||
return 0
|
||||
;;
|
||||
esac
|
||||
}
|
||||
complete -F _claudemesh_complete claudemesh
|
||||
`;
|
||||
}
|
||||
|
||||
function zsh(): string {
|
||||
return `#compdef claudemesh
|
||||
# claudemesh zsh completion
|
||||
|
||||
_claudemesh() {
|
||||
local -a commands flags
|
||||
commands=(
|
||||
${COMMANDS.map((c) => ` '${c}'`).join("\n")}
|
||||
)
|
||||
flags=(
|
||||
${FLAGS.map((f) => ` '${f}'`).join("\n")}
|
||||
)
|
||||
|
||||
if (( CURRENT == 2 )); then
|
||||
_describe 'command' commands
|
||||
return
|
||||
fi
|
||||
|
||||
case $words[2] in
|
||||
join|add|launch|connect)
|
||||
_arguments '--name[display name]' '--join[invite url]' '-y[non-interactive]' '--mesh[mesh slug]'
|
||||
;;
|
||||
share|invite)
|
||||
_arguments '--mesh[mesh slug]' '--json[machine-readable]'
|
||||
;;
|
||||
*)
|
||||
_values 'flag' $flags
|
||||
;;
|
||||
esac
|
||||
}
|
||||
compdef _claudemesh claudemesh
|
||||
`;
|
||||
}
|
||||
|
||||
function fish(): string {
|
||||
const cmdLines = COMMANDS.map(
|
||||
(c) => `complete -c claudemesh -n '__fish_use_subcommand' -a '${c}'`,
|
||||
).join("\n");
|
||||
return `# claudemesh fish completion
|
||||
${cmdLines}
|
||||
complete -c claudemesh -l help -s h -d 'show help'
|
||||
complete -c claudemesh -l version -s V -d 'show version'
|
||||
complete -c claudemesh -l json -d 'machine-readable output'
|
||||
complete -c claudemesh -l yes -s y -d 'skip confirmations'
|
||||
complete -c claudemesh -l mesh -d 'mesh slug'
|
||||
complete -c claudemesh -l name -d 'display name'
|
||||
complete -c claudemesh -l join -d 'invite url'
|
||||
`;
|
||||
}
|
||||
|
||||
export async function runCompletions(shell: string | undefined): Promise<number> {
|
||||
if (!shell) {
|
||||
console.error("Usage: claudemesh completions <bash|zsh|fish>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
switch (shell.toLowerCase()) {
|
||||
case "bash":
|
||||
process.stdout.write(bash());
|
||||
return EXIT.SUCCESS;
|
||||
case "zsh":
|
||||
process.stdout.write(zsh());
|
||||
return EXIT.SUCCESS;
|
||||
case "fish":
|
||||
process.stdout.write(fish());
|
||||
return EXIT.SUCCESS;
|
||||
default:
|
||||
console.error(`Unsupported shell: ${shell}. Use bash, zsh, or fish.`);
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { loadConfig } from "../state/config";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
export async function connectTelegram(args: string[]): Promise<void> {
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run 'claudemesh join' first.");
|
||||
process.exit(1);
|
||||
|
||||
@@ -6,22 +6,47 @@
|
||||
*/
|
||||
|
||||
import { hostname } from "node:os";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
import { loadConfig } from "../state/config";
|
||||
import type { JoinedMesh } from "../state/config";
|
||||
import { createInterface } from "node:readline";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import type { JoinedMesh } from "~/services/config/facade.js";
|
||||
|
||||
export interface ConnectOpts {
|
||||
/** Mesh slug to connect to. Auto-selects if only one mesh joined. */
|
||||
meshSlug?: string | null;
|
||||
/** Display name for this session. Defaults to hostname-pid. */
|
||||
displayName?: string;
|
||||
/** Connect to all meshes and run fn for each. */
|
||||
all?: boolean;
|
||||
}
|
||||
|
||||
async function pickMesh(meshes: JoinedMesh[]): Promise<JoinedMesh> {
|
||||
console.log("\n Select mesh:");
|
||||
meshes.forEach((m, i) => {
|
||||
console.log(` ${i + 1}) ${m.slug}`);
|
||||
});
|
||||
console.log("");
|
||||
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(" Choice [1]: ", (answer) => {
|
||||
rl.close();
|
||||
const idx = parseInt(answer || "1", 10) - 1;
|
||||
if (idx >= 0 && idx < meshes.length) {
|
||||
resolve(meshes[idx]!);
|
||||
} else {
|
||||
console.error(" Invalid choice, using first mesh.");
|
||||
resolve(meshes[0]!);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function withMesh<T>(
|
||||
opts: ConnectOpts,
|
||||
fn: (client: BrokerClient, mesh: JoinedMesh) => Promise<T>,
|
||||
): Promise<T> {
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
@@ -40,10 +65,7 @@ export async function withMesh<T>(
|
||||
} else if (config.meshes.length === 1) {
|
||||
mesh = config.meshes[0]!;
|
||||
} else {
|
||||
console.error(
|
||||
`Multiple meshes joined. Specify one with --mesh <slug>.\nJoined: ${config.meshes.map((m) => m.slug).join(", ")}`,
|
||||
);
|
||||
process.exit(1);
|
||||
mesh = await pickMesh(config.meshes);
|
||||
}
|
||||
|
||||
const displayName = opts.displayName ?? config.displayName ?? `${hostname()}-${process.pid}`;
|
||||
|
||||
@@ -1,39 +0,0 @@
|
||||
/**
|
||||
* `claudemesh create` — Create a new mesh with an optional template.
|
||||
* Lists available templates if --list-templates is passed.
|
||||
*/
|
||||
import { listTemplates, getTemplate } from "../templates/index.js";
|
||||
|
||||
export function runCreate(args: Record<string, unknown>): void {
|
||||
if (args["list-templates"]) {
|
||||
console.log("Available mesh templates:\n");
|
||||
for (const t of listTemplates()) {
|
||||
console.log(` ${t.name}`);
|
||||
console.log(` ${t.description}`);
|
||||
console.log(` Groups: ${t.groups.map((g) => g.name).join(", ") || "(none)"}`);
|
||||
console.log(` State keys: ${Object.keys(t.stateKeys).join(", ") || "(none)"}`);
|
||||
console.log();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const templateName = args.template as string | undefined;
|
||||
if (templateName) {
|
||||
const template = getTemplate(templateName);
|
||||
if (!template) {
|
||||
console.error(`Unknown template "${templateName}". Use --list-templates to see available options.`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(`Template "${template.name}" loaded:`);
|
||||
console.log(` Groups: ${template.groups.map((g) => `@${g.name}`).join(", ")}`);
|
||||
console.log(` State keys: ${Object.keys(template.stateKeys).join(", ")}`);
|
||||
console.log(` Hint: ${template.systemPromptHint.slice(0, 80)}...`);
|
||||
console.log();
|
||||
console.log("Template applied. Use `claudemesh launch` with --groups to join the predefined groups.");
|
||||
// Future: wire into actual mesh creation API
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("Usage: claudemesh create --template <name>");
|
||||
console.log(" claudemesh create --list-templates");
|
||||
}
|
||||
128
apps/cli/src/commands/delete-mesh.ts
Normal file
128
apps/cli/src/commands/delete-mesh.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { leave as leaveMesh } from "~/services/mesh/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { green, red, bold, dim, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
function getUserId(token: string): string {
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
return payload.sub ?? "";
|
||||
} catch { return ""; }
|
||||
}
|
||||
|
||||
async function isOwner(slug: string, userId: string): Promise<boolean> {
|
||||
try {
|
||||
const res = await request<{ meshes: Array<{ slug: string; is_owner: boolean }> }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
return res.meshes?.find(m => m.slug === slug)?.is_owner ?? false;
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
export async function deleteMesh(slug: string, opts: { yes?: boolean } = {}): Promise<number> {
|
||||
const config = readConfig();
|
||||
|
||||
// Mesh picker if no slug given
|
||||
if (!slug) {
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(" No meshes to remove.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
console.log("\n Select mesh to remove:\n");
|
||||
config.meshes.forEach((m, i) => {
|
||||
console.log(` ${bold(String(i + 1) + ")")} ${m.slug} ${dim("(" + m.name + ")")}`);
|
||||
});
|
||||
console.log("");
|
||||
const choice = await prompt(" Choice: ");
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
if (idx < 0 || idx >= config.meshes.length) {
|
||||
console.log(" Cancelled.");
|
||||
return EXIT.USER_CANCELLED;
|
||||
}
|
||||
slug = config.meshes[idx]!.slug;
|
||||
}
|
||||
|
||||
const auth = getStoredToken();
|
||||
const userId = auth ? getUserId(auth.session_token) : "";
|
||||
const ownerCheck = userId ? await isOwner(slug, userId) : false;
|
||||
|
||||
// Ask what to do
|
||||
if (!opts.yes) {
|
||||
console.log(`\n ${bold(slug)}\n`);
|
||||
|
||||
if (ownerCheck) {
|
||||
console.log(` ${bold("1)")} Remove from this device only ${dim("(keep on server)")}`);
|
||||
console.log(` ${bold("2)")} ${red("Delete everywhere")} ${dim("(removes for all members)")}`);
|
||||
console.log(` ${bold("3)")} Cancel`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "3") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
|
||||
|
||||
if (choice === "2") {
|
||||
// Server-side delete — require confirmation
|
||||
console.log(`\n ${red("Warning:")} This will delete ${bold(slug)} for all members.`);
|
||||
const confirm = await prompt(` Type "${slug}" to confirm: `);
|
||||
if (confirm.toLowerCase() !== slug.toLowerCase()) {
|
||||
console.log(" Cancelled.");
|
||||
return EXIT.USER_CANCELLED;
|
||||
}
|
||||
|
||||
try {
|
||||
await request({
|
||||
path: `/cli/mesh/${slug}`,
|
||||
method: "DELETE",
|
||||
body: { user_id: userId },
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
console.log(` ${green(icons.check)} Deleted "${slug}" from server.`);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
console.error(` ${icons.cross} Server delete failed: ${msg}`);
|
||||
}
|
||||
|
||||
leaveMesh(slug);
|
||||
console.log(` ${green(icons.check)} Removed from local config.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
// choice === "1" — local only, fall through
|
||||
} else {
|
||||
// Not owner — can only remove locally
|
||||
console.log(` ${bold("1)")} Remove from this device ${dim("(you can re-add later)")}`);
|
||||
console.log(` ${bold("2)")} Cancel`);
|
||||
if (!ownerCheck && userId) {
|
||||
console.log(dim(`\n ${yellow(icons.warn)} Only the mesh owner can delete it from the server.`));
|
||||
}
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
if (choice === "2") { console.log(" Cancelled."); return EXIT.USER_CANCELLED; }
|
||||
}
|
||||
}
|
||||
|
||||
// Local-only removal
|
||||
const removed = leaveMesh(slug);
|
||||
if (removed) {
|
||||
console.log(` ${green(icons.check)} Removed "${slug}" from this device.`);
|
||||
console.log(dim(` Re-add anytime with: claudemesh mesh add <invite-url>`));
|
||||
} else {
|
||||
console.error(` Mesh "${slug}" not found in local config.`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export async function disconnectTelegram(): Promise<void> {
|
||||
console.log("To disconnect Telegram, send /disconnect in the bot chat.");
|
||||
}
|
||||
@@ -10,8 +10,8 @@ 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";
|
||||
import { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { VERSION, URLS } from "~/constants/urls.js";
|
||||
|
||||
interface Check {
|
||||
name: string;
|
||||
@@ -110,7 +110,7 @@ function checkConfigFile(): Check {
|
||||
};
|
||||
}
|
||||
try {
|
||||
loadConfig();
|
||||
readConfig();
|
||||
const st = statSync(path);
|
||||
const mode = (st.mode & 0o777).toString(8);
|
||||
const secure = platform() === "win32" || mode === "600";
|
||||
@@ -132,7 +132,7 @@ function checkConfigFile(): Check {
|
||||
|
||||
function checkKeypairs(): Check {
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
const cfg = readConfig();
|
||||
if (cfg.meshes.length === 0) {
|
||||
return {
|
||||
name: "Mesh keypairs valid",
|
||||
@@ -172,6 +172,73 @@ function checkKeypairs(): Check {
|
||||
}
|
||||
}
|
||||
|
||||
async function checkBrokerWs(): Promise<Check> {
|
||||
const wsUrl = URLS.BROKER;
|
||||
const start = Date.now();
|
||||
try {
|
||||
const WebSocket = (await import("ws")).default;
|
||||
const ws = new WebSocket(wsUrl);
|
||||
const result = await new Promise<Check>((resolve) => {
|
||||
const timer = setTimeout(() => {
|
||||
try { ws.close(); } catch { /* noop */ }
|
||||
resolve({
|
||||
name: "Broker WebSocket reachable",
|
||||
pass: false,
|
||||
detail: `timeout after 5s (${wsUrl})`,
|
||||
fix: "Check firewall/proxy. Broker at ic.claudemesh.com:443 over WSS.",
|
||||
});
|
||||
}, 5000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
const latency = Date.now() - start;
|
||||
try { ws.close(); } catch { /* noop */ }
|
||||
resolve({
|
||||
name: "Broker WebSocket reachable",
|
||||
pass: true,
|
||||
detail: `${latency}ms to ${wsUrl}`,
|
||||
});
|
||||
});
|
||||
ws.once("error", (e) => {
|
||||
clearTimeout(timer);
|
||||
resolve({
|
||||
name: "Broker WebSocket reachable",
|
||||
pass: false,
|
||||
detail: e.message,
|
||||
fix: "Check network. Broker URL can be overridden via CLAUDEMESH_BROKER_URL.",
|
||||
});
|
||||
});
|
||||
});
|
||||
return result;
|
||||
} catch (e) {
|
||||
return {
|
||||
name: "Broker WebSocket reachable",
|
||||
pass: false,
|
||||
detail: e instanceof Error ? e.message : String(e),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function checkNpmLatest(): Promise<Check> {
|
||||
try {
|
||||
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(5000) });
|
||||
if (!res.ok) {
|
||||
return { name: "CLI up-to-date", pass: true, detail: `npm unreachable (${res.status}) — skipped` };
|
||||
}
|
||||
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
|
||||
const latest = body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest;
|
||||
if (!latest) return { name: "CLI up-to-date", pass: true, detail: "no dist-tag — skipped" };
|
||||
const up = latest === VERSION;
|
||||
return {
|
||||
name: "CLI up-to-date",
|
||||
pass: up,
|
||||
detail: up ? `latest ${latest}` : `installed ${VERSION} → latest ${latest}`,
|
||||
fix: up ? undefined : "npm i -g claudemesh-cli@alpha",
|
||||
};
|
||||
} catch {
|
||||
return { name: "CLI up-to-date", pass: true, detail: "npm check skipped" };
|
||||
}
|
||||
}
|
||||
|
||||
export async function runDoctor(): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
@@ -189,6 +256,8 @@ export async function runDoctor(): Promise<void> {
|
||||
checkHooksRegistered(),
|
||||
checkConfigFile(),
|
||||
checkKeypairs(),
|
||||
await checkBrokerWs(),
|
||||
await checkNpmLatest(),
|
||||
];
|
||||
|
||||
for (const c of checks) {
|
||||
|
||||
208
apps/cli/src/commands/grants.ts
Normal file
208
apps/cli/src/commands/grants.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
/**
|
||||
* `claudemesh grant / revoke / grants / block` — per-peer capability grants.
|
||||
*
|
||||
* Claudemesh's original threat model treats all mesh members as trusted, so
|
||||
* every peer can send you messages and read your summary. These commands add
|
||||
* a local filter: the broker still forwards messages, but the MCP server
|
||||
* drops disallowed kinds before they reach Claude Code.
|
||||
*
|
||||
* Grants are stored in ~/.claudemesh/grants.json keyed on
|
||||
* (mesh_slug, peer_pubkey). Default = read + dm (backwards-compatible).
|
||||
* The `block` command sets an empty grant set (equivalent to revoke-all).
|
||||
*
|
||||
* Full grant-enforcement on the broker side is out of scope for this pass
|
||||
* — see .artifacts/specs/2026-04-15-per-peer-capabilities.md for the
|
||||
* server-side rollout plan. Client-side enforcement handles the 80% case
|
||||
* (spam / noise) without needing a broker migration.
|
||||
*/
|
||||
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { withMesh } from "./connect.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
// Mirror local grant edits to the broker so enforcement happens server-side
|
||||
// as well as client-side (spec: 2026-04-15-per-peer-capabilities.md). Fails
|
||||
// open — if sync fails the client filter still drops disallowed messages.
|
||||
async function syncToBroker(meshSlug: string, grants: Record<string, string[] | null>): Promise<void> {
|
||||
const auth = getStoredToken();
|
||||
if (!auth) return;
|
||||
let userId = "";
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
userId = payload.sub ?? "";
|
||||
} catch { return; }
|
||||
if (!userId) return;
|
||||
try {
|
||||
await request<{ ok: true }>({
|
||||
path: `/cli/mesh/${meshSlug}/grants`,
|
||||
method: "POST",
|
||||
body: { user_id: userId, grants },
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
} catch (e) {
|
||||
render.warn(`broker grant sync failed — client filter still active: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export type Capability =
|
||||
| "read"
|
||||
| "dm"
|
||||
| "broadcast"
|
||||
| "state-read"
|
||||
| "state-write"
|
||||
| "file-read";
|
||||
|
||||
const ALL_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read", "state-write", "file-read"];
|
||||
const DEFAULT_CAPS: Capability[] = ["read", "dm", "broadcast", "state-read"];
|
||||
|
||||
type GrantStore = Record<string, Record<string, Capability[]>>; // mesh → pubkey → caps
|
||||
|
||||
const GRANT_FILE = join(homedir(), ".claudemesh", "grants.json");
|
||||
|
||||
function readGrants(): GrantStore {
|
||||
if (!existsSync(GRANT_FILE)) return {};
|
||||
try {
|
||||
return JSON.parse(readFileSync(GRANT_FILE, "utf-8")) as GrantStore;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function writeGrants(g: GrantStore): void {
|
||||
const dir = join(homedir(), ".claudemesh");
|
||||
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||
writeFileSync(GRANT_FILE, JSON.stringify(g, null, 2), { mode: 0o600 });
|
||||
}
|
||||
|
||||
function resolveCaps(input: string[]): Capability[] {
|
||||
if (input.includes("all")) return [...ALL_CAPS];
|
||||
return input.filter((c): c is Capability => (ALL_CAPS as string[]).includes(c));
|
||||
}
|
||||
|
||||
async function resolvePeer(meshSlug: string, name: string): Promise<{ displayName: string; pubkey: string } | null> {
|
||||
return await withMesh({ meshSlug }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
const match = peers.find((p) => p.displayName === name || p.pubkey === name || p.pubkey.startsWith(name));
|
||||
return match ? { displayName: match.displayName, pubkey: match.pubkey } : null;
|
||||
});
|
||||
}
|
||||
|
||||
function pickMesh(slug?: string): string | null {
|
||||
const cfg = readConfig();
|
||||
if (slug) return cfg.meshes.find((m) => m.slug === slug) ? slug : null;
|
||||
return cfg.meshes[0]?.slug ?? null;
|
||||
}
|
||||
|
||||
export async function runGrant(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
|
||||
if (!peer || caps.length === 0) {
|
||||
render.err("Usage: claudemesh grant <peer> <capability...>");
|
||||
render.hint(`Capabilities: ${ALL_CAPS.join(", ")}, all`);
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const mesh = pickMesh(opts.mesh);
|
||||
if (!mesh) { render.err("No matching mesh — join one first."); return EXIT.NOT_FOUND; }
|
||||
const resolved = await resolvePeer(mesh, peer);
|
||||
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
|
||||
const wanted = resolveCaps(caps);
|
||||
if (wanted.length === 0) { render.err(`Unknown capabilities: ${caps.join(", ")}`); return EXIT.INVALID_ARGS; }
|
||||
|
||||
const store = readGrants();
|
||||
const meshGrants = store[mesh] ?? {};
|
||||
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
|
||||
const merged = Array.from(new Set([...existing, ...wanted]));
|
||||
meshGrants[resolved.pubkey] = merged;
|
||||
store[mesh] = meshGrants;
|
||||
writeGrants(store);
|
||||
await syncToBroker(mesh, { [resolved.pubkey]: merged });
|
||||
|
||||
render.ok(`Granted ${wanted.join(", ")} to ${resolved.displayName} on ${mesh}.`);
|
||||
render.kv([["now", merged.join(", ")]]);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runRevoke(peer: string | undefined, caps: string[], opts: { mesh?: string } = {}): Promise<number> {
|
||||
if (!peer || caps.length === 0) {
|
||||
render.err("Usage: claudemesh revoke <peer> <capability...>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
const mesh = pickMesh(opts.mesh);
|
||||
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
|
||||
const resolved = await resolvePeer(mesh, peer);
|
||||
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
|
||||
const wanted = caps.includes("all") ? ALL_CAPS.slice() : resolveCaps(caps);
|
||||
|
||||
const store = readGrants();
|
||||
const meshGrants = store[mesh] ?? {};
|
||||
const existing = meshGrants[resolved.pubkey] ?? DEFAULT_CAPS.slice();
|
||||
const after = existing.filter((c) => !wanted.includes(c));
|
||||
meshGrants[resolved.pubkey] = after;
|
||||
store[mesh] = meshGrants;
|
||||
writeGrants(store);
|
||||
await syncToBroker(mesh, { [resolved.pubkey]: after });
|
||||
|
||||
render.ok(`Revoked ${wanted.join(", ")} from ${resolved.displayName} on ${mesh}.`);
|
||||
render.kv([["now", after.length ? after.join(", ") : "(none)"]]);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runBlock(peer: string | undefined, opts: { mesh?: string } = {}): Promise<number> {
|
||||
if (!peer) { render.err("Usage: claudemesh block <peer>"); return EXIT.INVALID_ARGS; }
|
||||
const mesh = pickMesh(opts.mesh);
|
||||
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
|
||||
const resolved = await resolvePeer(mesh, peer);
|
||||
if (!resolved) { render.err(`Peer "${peer}" not found on ${mesh}.`); return EXIT.NOT_FOUND; }
|
||||
const store = readGrants();
|
||||
const meshGrants = store[mesh] ?? {};
|
||||
meshGrants[resolved.pubkey] = [];
|
||||
store[mesh] = meshGrants;
|
||||
writeGrants(store);
|
||||
await syncToBroker(mesh, { [resolved.pubkey]: [] });
|
||||
render.ok(`Blocked ${resolved.displayName} on ${mesh} (all capabilities revoked).`);
|
||||
render.hint(`Undo with: claudemesh grant ${resolved.displayName} all --mesh ${mesh}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runGrants(opts: { mesh?: string; json?: boolean } = {}): Promise<number> {
|
||||
const mesh = pickMesh(opts.mesh);
|
||||
if (!mesh) { render.err("No matching mesh."); return EXIT.NOT_FOUND; }
|
||||
const store = readGrants();
|
||||
const meshGrants = store[mesh] ?? {};
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", mesh, grants: meshGrants }, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
render.section(`grants on ${mesh}`);
|
||||
const peerPubkeys = Object.keys(meshGrants);
|
||||
if (peerPubkeys.length === 0) {
|
||||
render.info("(no overrides — all peers use default caps: " + DEFAULT_CAPS.join(", ") + ")");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
await withMesh({ meshSlug: mesh }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
const byPk = new Map(peers.map((p) => [p.pubkey, p.displayName]));
|
||||
for (const [pk, caps] of Object.entries(meshGrants)) {
|
||||
const name = byPk.get(pk) ?? `${pk.slice(0, 10)}…`;
|
||||
render.kv([[name, caps.length ? caps.join(", ") : "(blocked)"]]);
|
||||
}
|
||||
});
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
/** Used by the MCP inbound-message path. Returns true if the capability is allowed. */
|
||||
export function isAllowed(meshSlug: string, peerPubkey: string, cap: Capability): boolean {
|
||||
const store = readGrants();
|
||||
const entry = store[meshSlug]?.[peerPubkey];
|
||||
if (entry === undefined) return DEFAULT_CAPS.includes(cap);
|
||||
return entry.includes(cap);
|
||||
}
|
||||
@@ -23,7 +23,7 @@
|
||||
* in pending_status (harmless, TTL-swept).
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../state/config";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
|
||||
|
||||
@@ -100,7 +100,7 @@ export async function runHook(args: string[]): Promise<void> {
|
||||
// Fan out to EVERY joined mesh's broker in parallel.
|
||||
let config;
|
||||
try {
|
||||
config = loadConfig();
|
||||
config = readConfig();
|
||||
} catch (e) {
|
||||
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(0);
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* Works best when message-mode is "inbox" or "off" (messages held at broker).
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { InboundPush } from "../ws/client";
|
||||
import { withMesh } from "./connect.js";
|
||||
import type { InboundPush } from "~/services/broker/facade.js";
|
||||
|
||||
export interface InboxFlags {
|
||||
mesh?: string;
|
||||
|
||||
29
apps/cli/src/commands/index.ts
Normal file
29
apps/cli/src/commands/index.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
export { runJoin } from "./join.js";
|
||||
export { newMesh } from "./new.js";
|
||||
export { invite } from "./invite.js";
|
||||
export { runList } from "./list.js";
|
||||
export { rename } from "./rename.js";
|
||||
export { runLeave } from "./leave.js";
|
||||
export { runPeers } from "./peers.js";
|
||||
export { runSend } from "./send.js";
|
||||
export { runInbox } from "./inbox.js";
|
||||
export { runStateGet, runStateSet } from "./state.js";
|
||||
export { runInfo } from "./info.js";
|
||||
export { remember } from "./remember.js";
|
||||
export { recall } from "./recall.js";
|
||||
export { runRemind } from "./remind.js";
|
||||
export { runProfile } from "./profile.js";
|
||||
export { runStatus } from "./status.js";
|
||||
export { runDoctor } from "./doctor.js";
|
||||
export { register } from "./register.js";
|
||||
export { login } from "./login.js";
|
||||
export { logout } from "./logout.js";
|
||||
export { whoami } from "./whoami.js";
|
||||
export { runInstall } from "./install.js";
|
||||
export { uninstall } from "./uninstall.js";
|
||||
export { runSync } from "./sync.js";
|
||||
export { runWelcome } from "./welcome.js";
|
||||
export { runHook } from "./hook.js";
|
||||
export { runMcp } from "./mcp.js";
|
||||
export { runSeedTestMesh } from "./seed-test-mesh.js";
|
||||
export { withMesh } from "./connect.js";
|
||||
@@ -4,8 +4,8 @@
|
||||
* Useful for AI agents to orient themselves in a mesh via bash.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import { loadConfig } from "../state/config";
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
export interface InfoFlags {
|
||||
mesh?: string;
|
||||
@@ -18,7 +18,7 @@ export async function runInfo(flags: InfoFlags): Promise<void> {
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const [brokerInfo, peers, state] = await Promise.all([
|
||||
|
||||
@@ -29,7 +29,7 @@ import { homedir, platform } from "node:os";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { loadConfig } from "../state/config";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
const MCP_NAME = "claudemesh";
|
||||
const CLAUDE_CONFIG = join(homedir(), ".claude.json");
|
||||
@@ -356,8 +356,25 @@ function uninstallHooks(): number {
|
||||
return removed;
|
||||
}
|
||||
|
||||
function installStatusLine(): { installed: boolean } {
|
||||
const settings = readClaudeSettings();
|
||||
const cmd = `claudemesh status-line`;
|
||||
const current = (settings as { statusLine?: { command?: string } }).statusLine;
|
||||
// If the user has their own statusLine command, don't clobber it.
|
||||
if (current?.command && !current.command.includes("claudemesh status-line")) {
|
||||
return { installed: false };
|
||||
}
|
||||
(settings as { statusLine?: { type: string; command: string } }).statusLine = {
|
||||
type: "command",
|
||||
command: cmd,
|
||||
};
|
||||
writeClaudeSettings(settings);
|
||||
return { installed: true };
|
||||
}
|
||||
|
||||
export function runInstall(args: string[] = []): void {
|
||||
const skipHooks = args.includes("--no-hooks");
|
||||
const wantStatusLine = args.includes("--status-line");
|
||||
console.log("claudemesh install");
|
||||
console.log("------------------");
|
||||
|
||||
@@ -452,10 +469,25 @@ export function runInstall(args: string[] = []): void {
|
||||
console.log(dim("· Hooks skipped (--no-hooks)"));
|
||||
}
|
||||
|
||||
// Opt-in status line (shows mesh + peer count in Claude Code).
|
||||
if (wantStatusLine) {
|
||||
try {
|
||||
const { installed } = installStatusLine();
|
||||
if (installed) {
|
||||
console.log(`✓ Claude Code statusLine → \`claudemesh status-line\``);
|
||||
console.log(dim(` Shows: ◇ <mesh> · <online>/<total> online · <you>`));
|
||||
} else {
|
||||
console.log(dim("· statusLine already set to a custom command — left alone"));
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(`⚠ statusLine install failed: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Check if user has any meshes joined — nudge them if not.
|
||||
let hasMeshes = false;
|
||||
try {
|
||||
const meshConfig = loadConfig();
|
||||
const meshConfig = readConfig();
|
||||
hasMeshes = meshConfig.meshes.length > 0;
|
||||
} catch {
|
||||
// Config missing or corrupt — treat as no meshes.
|
||||
@@ -468,8 +500,8 @@ export function runInstall(args: string[] = []): void {
|
||||
console.log("");
|
||||
console.log(yellow("No meshes joined.") + " To connect with peers:");
|
||||
console.log(
|
||||
` ${bold("claudemesh join <invite-url>")}` +
|
||||
dim(" — join an existing mesh"),
|
||||
` ${bold("claudemesh <invite-url>")}` +
|
||||
dim(" — joins + launches in one step"),
|
||||
);
|
||||
console.log(
|
||||
` ${dim("Create one at")} ${bold("https://claudemesh.com/dashboard")}`,
|
||||
@@ -477,21 +509,15 @@ export function runInstall(args: string[] = []): void {
|
||||
} else {
|
||||
console.log("");
|
||||
console.log(
|
||||
`Next: ${bold("claudemesh join https://claudemesh.com/join/<token>")}`,
|
||||
`Next: ${bold("claudemesh")}` + dim(" — launch with your joined mesh"),
|
||||
);
|
||||
}
|
||||
|
||||
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."),
|
||||
);
|
||||
console.log(dim("Optional:"));
|
||||
console.log(dim(` claudemesh url-handler install # click-to-launch from email`));
|
||||
console.log(dim(` claudemesh install --status-line # live peer count in Claude Code`));
|
||||
console.log(dim(` claudemesh completions zsh # shell completions`));
|
||||
}
|
||||
|
||||
export function runUninstall(): void {
|
||||
|
||||
96
apps/cli/src/commands/invite.ts
Normal file
96
apps/cli/src/commands/invite.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { generateInvite } from "~/services/invite/generate.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { writeClipboard } from "~/services/clipboard/facade.js";
|
||||
import { green, bold, dim, icons } from "~/ui/styles.js";
|
||||
import { renderQrAsync } from "~/ui/qr.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (a) => { rl.close(); resolve(a.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
export async function invite(
|
||||
email?: string,
|
||||
opts: { mesh?: string; expires?: string; uses?: number; role?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const auth = getStoredToken();
|
||||
if (!auth) {
|
||||
console.error(" Not signed in. Run `claudemesh login` first.");
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error(" No meshes. Create one with `claudemesh mesh create <name>`.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
// Resolve which mesh to share
|
||||
let meshSlug = opts.mesh;
|
||||
if (!meshSlug) {
|
||||
if (config.meshes.length === 1) {
|
||||
meshSlug = config.meshes[0]!.slug;
|
||||
} else {
|
||||
// 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("");
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
const idx = parseInt(choice, 10) - 1;
|
||||
meshSlug = config.meshes[idx >= 0 && idx < config.meshes.length ? idx : 0]!.slug;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await generateInvite(meshSlug, {
|
||||
email,
|
||||
expires_in: opts.expires ?? "7d",
|
||||
max_uses: opts.uses,
|
||||
role: opts.role,
|
||||
});
|
||||
|
||||
const copied = writeClipboard(result.url);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result, copied }, null, 2));
|
||||
} else {
|
||||
if (email) {
|
||||
if (result.emailed) {
|
||||
console.log(`\n ${green(icons.check)} Invite sent to ${bold(email)}`);
|
||||
if (copied) console.log(` ${green(icons.check)} Link also copied to clipboard`);
|
||||
} else {
|
||||
console.log(`\n ${icons.cross} Email to ${bold(email)} was NOT sent (server did not send).`);
|
||||
console.log(` ${dim("Share the link manually:")}`);
|
||||
console.log(` ${result.url}`);
|
||||
if (copied) console.log(` ${green(icons.check)} Link copied to clipboard`);
|
||||
}
|
||||
} else {
|
||||
console.log(`\n ${green(icons.check)} Invite link${copied ? " copied to clipboard" : ""}:`);
|
||||
console.log(` ${result.url}`);
|
||||
// Print QR for phone→laptop pairing. Small variant is ~17 lines tall.
|
||||
const qr = await renderQrAsync(result.url, { small: true });
|
||||
console.log("");
|
||||
for (const line of qr.split("\n")) console.log(` ${line}`);
|
||||
}
|
||||
console.log(`\n ${dim("Expires " + result.expires_at + ". Anyone with this link can join \"" + meshSlug + "\".")}\n`);
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("403") || msg.includes("permission")) {
|
||||
console.error(` ${icons.cross} You don't have permission to invite to "${meshSlug}".`);
|
||||
console.error(` ${dim("Ask the mesh owner to grant you invite permissions.")}`);
|
||||
} else {
|
||||
console.error(` ${icons.cross} Failed: ${msg}`);
|
||||
}
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,16 @@
|
||||
* v1 continues to work throughout v0.1.x. v1 endpoints 410 Gone at v0.2.0.
|
||||
*/
|
||||
|
||||
import { parseInviteLink } from "../invite/parse";
|
||||
import { enrollWithBroker } from "../invite/enroll";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
import { loadConfig, saveConfig, getConfigPath } from "../state/config";
|
||||
import { claimInviteV2, parseV2InviteInput } from "../lib/invite-v2";
|
||||
import { parseInviteLink } from "~/services/invite/facade.js";
|
||||
import { enrollWithBroker } from "~/services/invite/facade.js";
|
||||
import { generateKeypair } from "~/services/crypto/facade.js";
|
||||
import { readConfig, writeConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { claimInviteV2, parseV2InviteInput } from "~/services/invite/facade.js";
|
||||
import sodium from "libsodium-wrappers";
|
||||
import { writeFileSync, mkdirSync } from "node:fs";
|
||||
import { join, dirname } from "node:path";
|
||||
import { homedir, hostname } from "node:os";
|
||||
import { env } from "../env";
|
||||
import { env } from "~/constants/urls.js";
|
||||
|
||||
/** Derive the web app base URL from the broker URL, unless explicitly overridden. */
|
||||
function deriveAppBaseUrl(): string {
|
||||
@@ -73,7 +73,7 @@ async function runJoinV2(code: string): Promise<void> {
|
||||
// stable short derivative of the mesh id so `list` / `launch --mesh`
|
||||
// still have something to match on.
|
||||
const fallbackSlug = `mesh-${claim.meshId.slice(0, 8)}`;
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
config.meshes = config.meshes.filter((m) => m.meshId !== claim.meshId);
|
||||
config.meshes.push({
|
||||
meshId: claim.meshId,
|
||||
@@ -87,7 +87,7 @@ async function runJoinV2(code: string): Promise<void> {
|
||||
rootKey: rootKeyB64,
|
||||
inviteVersion: 2,
|
||||
});
|
||||
saveConfig(config);
|
||||
writeConfig(config);
|
||||
|
||||
console.log("");
|
||||
console.log(`✓ Joined mesh ${claim.meshId} via v2 invite`);
|
||||
@@ -153,7 +153,7 @@ export async function runJoin(args: string[]): Promise<void> {
|
||||
}
|
||||
|
||||
// 4. Persist.
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
config.meshes = config.meshes.filter(
|
||||
(m) => m.slug !== payload.mesh_slug,
|
||||
);
|
||||
@@ -167,7 +167,7 @@ export async function runJoin(args: string[]): Promise<void> {
|
||||
brokerUrl: payload.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
saveConfig(config);
|
||||
writeConfig(config);
|
||||
|
||||
// 4b. Store invite token for per-session re-enrollment (launch --name).
|
||||
const configDir = env.CLAUDEMESH_CONFIG_DIR ?? join(homedir(), ".claudemesh");
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// @ts-nocheck — v1 port, runtime-tested
|
||||
/**
|
||||
* `claudemesh launch` — spawn `claude` with peer mesh identity.
|
||||
*
|
||||
@@ -19,10 +20,11 @@ import { mkdtempSync, writeFileSync, rmSync, readdirSync, statSync, existsSync,
|
||||
import { tmpdir, hostname, homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { createInterface } from "node:readline";
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode } from "../auth";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
import { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import type { Config, JoinedMesh, GroupEntry } from "~/services/config/facade.js";
|
||||
import { startCallbackListener, generatePairingCode } from "~/services/auth/facade.js";
|
||||
import { openBrowser } from "~/services/spawn/facade.js";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
|
||||
// Flags as parsed by citty (index.ts is the source of truth for definitions).
|
||||
export interface LaunchFlags {
|
||||
@@ -132,12 +134,12 @@ async function confirmPermissions(): Promise<void> {
|
||||
import {
|
||||
bold as tBold, dim as tDim, green as tGreen, orange as tOrange,
|
||||
boldOrange, HIDE_CURSOR, SHOW_CURSOR,
|
||||
} from "../tui/colors";
|
||||
} from "~/ui/styles.js";
|
||||
import {
|
||||
enterFullScreen, exitFullScreen, writeCentered, termSize,
|
||||
drawTopBar, drawBottomBar, menuSelect, textInput, confirmPrompt,
|
||||
} from "../tui/screen";
|
||||
import { createSpinner, FRAME_HEIGHT } from "../tui/spinner";
|
||||
} from "~/ui/screen.js";
|
||||
import { createSpinner, FRAME_HEIGHT } from "~/ui/spinner.js";
|
||||
|
||||
interface LaunchWizardResult {
|
||||
mesh: JoinedMesh;
|
||||
@@ -372,7 +374,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
console.log("Joining mesh...");
|
||||
const invite = await parseInviteLink(args.joinLink);
|
||||
const keypair = await generateKeypair();
|
||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
|
||||
const enroll = await enrollWithBroker({
|
||||
brokerWsUrl: invite.payload.broker_url,
|
||||
inviteToken: invite.token,
|
||||
@@ -380,7 +382,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
peerPubkey: keypair.publicKey,
|
||||
displayName,
|
||||
});
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
config.meshes = config.meshes.filter(
|
||||
(m) => m.slug !== invite.payload.mesh_slug,
|
||||
);
|
||||
@@ -394,15 +396,15 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
brokerUrl: invite.payload.broker_url,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
const { saveConfig } = await import("../state/config");
|
||||
saveConfig(config);
|
||||
const { writeConfig } = await import("~/services/config/facade.js");
|
||||
writeConfig(config);
|
||||
console.log(
|
||||
`✓ Joined "${invite.payload.mesh_slug}"${enroll.alreadyMember ? " (already member)" : ""}`,
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Load config, pick mesh.
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
let justSynced = false;
|
||||
|
||||
if (config.meshes.length === 0 && !args.joinLink) {
|
||||
@@ -452,15 +454,15 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
}
|
||||
|
||||
// Generate keypair and sync with broker
|
||||
const { generateKeypair } = await import("../crypto/keypair");
|
||||
const { generateKeypair } = await import("~/services/crypto/facade.js");
|
||||
const keypair = await generateKeypair();
|
||||
const displayNameForSync = args.name ?? `${hostname()}-${process.pid}`;
|
||||
const displayNameForSync = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
|
||||
|
||||
const { syncWithBroker } = await import("../auth/sync-with-broker");
|
||||
const { syncWithBroker } = await import("~/services/auth/facade.js");
|
||||
const result = await syncWithBroker(syncToken, keypair.publicKey, displayNameForSync);
|
||||
|
||||
// Write all meshes to config
|
||||
const { saveConfig } = await import("../state/config");
|
||||
const { writeConfig } = await import("~/services/config/facade.js");
|
||||
for (const m of result.meshes) {
|
||||
config.meshes.push({
|
||||
meshId: m.mesh_id,
|
||||
@@ -474,7 +476,7 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
});
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
writeConfig(config);
|
||||
justSynced = true;
|
||||
|
||||
console.log(`\n ${green("✓")} Synced ${result.meshes.length} mesh(es): ${result.meshes.map(m => m.slug).join(", ")}\n`);
|
||||
@@ -504,13 +506,17 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
}
|
||||
|
||||
// 3. Session identity + role/groups via TUI wizard.
|
||||
const displayName = args.name ?? `${hostname()}-${process.pid}`;
|
||||
const displayName = (args.name ?? process.env.USER ?? process.env.USERNAME ?? hostname());
|
||||
|
||||
let role: string | null = args.role;
|
||||
let parsedGroups: GroupEntry[] = args.groups ? parseGroupsString(args.groups) : [];
|
||||
let messageMode: "push" | "inbox" | "off" = args.messageMode ?? "push";
|
||||
|
||||
if (!args.quiet && !justSynced) {
|
||||
// `-y` (skipPermConfirm) implies fully non-interactive — skip the wizard
|
||||
// entirely and use sensible defaults (role=member, no groups, push mode).
|
||||
// Same applies to `--quiet` and the post-sync path where we already picked.
|
||||
const nonInteractive = args.quiet || justSynced || args.skipPermConfirm;
|
||||
if (!nonInteractive) {
|
||||
const wizardResult = await runLaunchWizard({
|
||||
displayName,
|
||||
meshes: config.meshes,
|
||||
@@ -526,8 +532,8 @@ export async function runLaunch(flags: LaunchFlags, rawArgs: string[]): Promise<
|
||||
messageMode = wizardResult.messageMode;
|
||||
args.skipPermConfirm = wizardResult.skipPermissions;
|
||||
} else if (!mesh) {
|
||||
// Quiet mode + multiple meshes — fall back to old picker
|
||||
mesh = await pickMesh(config.meshes);
|
||||
// No mesh picked yet + non-interactive — pick the first one deterministically.
|
||||
mesh = config.meshes[0]!;
|
||||
}
|
||||
|
||||
// Clean up orphaned tmpdirs from crashed sessions (older than 1 hour)
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* best-effort revoke request before removing the entry.
|
||||
*/
|
||||
|
||||
import { loadConfig, saveConfig } from "../state/config";
|
||||
import { readConfig, writeConfig } from "~/services/config/facade.js";
|
||||
|
||||
export function runLeave(args: string[]): void {
|
||||
const slug = args[0];
|
||||
@@ -13,13 +13,13 @@ export function runLeave(args: string[]): void {
|
||||
console.error("Usage: claudemesh leave <slug>");
|
||||
process.exit(1);
|
||||
}
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
const before = config.meshes.length;
|
||||
config.meshes = config.meshes.filter((m) => m.slug !== slug);
|
||||
if (config.meshes.length === before) {
|
||||
console.error(`claudemesh: no joined mesh with slug "${slug}"`);
|
||||
process.exit(1);
|
||||
}
|
||||
saveConfig(config);
|
||||
writeConfig(config);
|
||||
console.log(`Left mesh "${slug}". Remaining: ${config.meshes.length}`);
|
||||
}
|
||||
|
||||
@@ -1,30 +1,104 @@
|
||||
/**
|
||||
* `claudemesh list` — show all joined meshes + their status.
|
||||
* `claudemesh mesh list` — merged view of server + local meshes.
|
||||
*/
|
||||
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { bold, dim, green, yellow, red } from "~/ui/styles.js";
|
||||
|
||||
export function runList(): void {
|
||||
const config = loadConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.log("No meshes joined yet.");
|
||||
console.log("");
|
||||
console.log(
|
||||
"Join one with: claudemesh join https://claudemesh.com/join/<token>",
|
||||
);
|
||||
console.log(`Config file: ${getConfigPath()}`);
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
interface ServerMesh {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
role: string;
|
||||
is_owner: boolean;
|
||||
member_count: number;
|
||||
active_peers: number;
|
||||
joined_at: string;
|
||||
}
|
||||
|
||||
export async function runList(): Promise<void> {
|
||||
const config = readConfig();
|
||||
const auth = getStoredToken();
|
||||
|
||||
// Try to fetch from server
|
||||
let serverMeshes: ServerMesh[] = [];
|
||||
if (auth) {
|
||||
try {
|
||||
let userId = "";
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
userId = payload.sub ?? "";
|
||||
} catch {}
|
||||
|
||||
if (userId) {
|
||||
const res = await request<{ meshes: ServerMesh[] }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
serverMeshes = res.meshes ?? [];
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Merge: server meshes + local-only meshes
|
||||
const localSlugs = new Set(config.meshes.map(m => m.slug));
|
||||
const serverSlugs = new Set(serverMeshes.map(m => m.slug));
|
||||
|
||||
const allSlugs = new Set([...localSlugs, ...serverSlugs]);
|
||||
|
||||
if (allSlugs.size === 0) {
|
||||
console.log("\n No meshes yet.\n");
|
||||
console.log(" Create one: claudemesh mesh create <name>");
|
||||
console.log(" Join one: claudemesh mesh add <invite-url>\n");
|
||||
return;
|
||||
}
|
||||
console.log(`Joined meshes (${config.meshes.length}):`);
|
||||
console.log("");
|
||||
for (const m of config.meshes) {
|
||||
console.log(` ${m.name} (${m.slug})`);
|
||||
console.log(` mesh id: ${m.meshId}`);
|
||||
console.log(` member id: ${m.memberId}`);
|
||||
console.log(` pubkey: ${m.pubkey.slice(0, 16)}…`);
|
||||
console.log(` broker: ${m.brokerUrl}`);
|
||||
console.log(` joined: ${m.joinedAt}`);
|
||||
console.log("");
|
||||
|
||||
console.log("\n Your meshes:\n");
|
||||
|
||||
for (const slug of allSlugs) {
|
||||
const local = config.meshes.find(m => m.slug === slug);
|
||||
const server = serverMeshes.find(m => m.slug === slug);
|
||||
|
||||
const name = server?.name ?? local?.name ?? slug;
|
||||
const role = server?.role ?? "member";
|
||||
const isOwner = server?.is_owner ?? false;
|
||||
const roleLabel = isOwner ? "owner" : role;
|
||||
const memberCount = server?.member_count;
|
||||
const activePeers = server?.active_peers ?? 0;
|
||||
|
||||
// Status indicator
|
||||
const inLocal = localSlugs.has(slug);
|
||||
const inServer = serverSlugs.has(slug);
|
||||
let status: string;
|
||||
let icon: string;
|
||||
|
||||
if (inLocal && inServer) {
|
||||
icon = green("●");
|
||||
status = activePeers > 0 ? green(`${activePeers} online`) : dim("synced");
|
||||
} else if (inLocal && !inServer) {
|
||||
icon = yellow("●");
|
||||
status = yellow("local only");
|
||||
} else {
|
||||
icon = dim("○");
|
||||
status = dim("not added locally");
|
||||
}
|
||||
|
||||
const memberInfo = memberCount ? dim(`${memberCount} member${memberCount !== 1 ? "s" : ""}`) : "";
|
||||
const parts = [roleLabel, memberInfo, status].filter(Boolean);
|
||||
|
||||
console.log(` ${icon} ${bold(name)} ${dim(slug)}`);
|
||||
console.log(` ${parts.join(" · ")}`);
|
||||
}
|
||||
console.log(`Config: ${getConfigPath()}`);
|
||||
|
||||
console.log("");
|
||||
if (serverMeshes.some(m => !localSlugs.has(m.slug))) {
|
||||
console.log(dim(" ○ = server only — run `claudemesh mesh add` to use locally"));
|
||||
}
|
||||
console.log(dim(` Config: ${getConfigPath()}`));
|
||||
console.log("");
|
||||
}
|
||||
|
||||
118
apps/cli/src/commands/login.ts
Normal file
118
apps/cli/src/commands/login.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import { createInterface } from "node:readline";
|
||||
import { loginWithDeviceCode, getStoredToken, clearToken, storeToken } from "~/services/auth/facade.js";
|
||||
import { my } from "~/services/api/facade.js";
|
||||
import { green, dim, bold, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
|
||||
function prompt(question: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => { rl.close(); resolve(answer.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
async function loginWithToken(): Promise<number> {
|
||||
console.log(`\n Paste a token from ${dim(URLS.API_BASE + "/token")}`);
|
||||
console.log(` ${dim("Generate one in your browser, then paste it here.")}\n`);
|
||||
|
||||
const token = await prompt(" Token: ");
|
||||
if (!token) {
|
||||
console.error(` ${icons.cross} No token provided.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
// Decode JWT to get user info
|
||||
let user = { id: "", display_name: "", email: "" };
|
||||
try {
|
||||
const parts = token.split(".");
|
||||
if (parts[1]) {
|
||||
const payload = JSON.parse(Buffer.from(parts[1], "base64url").toString()) as {
|
||||
sub?: string; email?: string; name?: string; exp?: number;
|
||||
};
|
||||
if (payload.exp && payload.exp < Date.now() / 1000) {
|
||||
console.error(` ${icons.cross} Token expired. Generate a new one.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
user = {
|
||||
id: payload.sub ?? "",
|
||||
display_name: payload.name ?? payload.email ?? "",
|
||||
email: payload.email ?? "",
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
console.error(` ${icons.cross} Invalid token format.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
storeToken({ session_token: token, user, token_source: "manual" });
|
||||
console.log(` ${green(icons.check)} Signed in as ${user.display_name || user.email || "user"}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
async function syncMeshes(token: string): Promise<void> {
|
||||
try {
|
||||
const meshes = await my.getMeshes(token);
|
||||
if (meshes.length > 0) {
|
||||
const names = meshes.map((m) => m.slug).join(", ");
|
||||
console.log(` ${green(icons.check)} Synced ${meshes.length} mesh${meshes.length === 1 ? "" : "es"}: ${names}`);
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export async function login(): Promise<number> {
|
||||
const existing = getStoredToken();
|
||||
if (existing) {
|
||||
const name = existing.user.display_name || existing.user.email || "unknown";
|
||||
console.log(`\n Already signed in as ${bold(name)}.`);
|
||||
console.log("");
|
||||
console.log(` ${bold("1)")} Continue as ${name}`);
|
||||
console.log(` ${bold("2)")} Sign in via browser`);
|
||||
console.log(` ${bold("3)")} Paste a token from ${dim("claudemesh.com/token")}`);
|
||||
console.log(` ${bold("4)")} Sign out`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "1") {
|
||||
console.log(`\n ${green(icons.check)} Continuing as ${name}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
if (choice === "4") {
|
||||
clearToken();
|
||||
console.log(` ${green(icons.check)} Signed out.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
if (choice === "3") {
|
||||
clearToken();
|
||||
return loginWithToken();
|
||||
}
|
||||
// choice === "2" → fall through to browser login
|
||||
clearToken();
|
||||
console.log(` ${dim("Signing in…")}`);
|
||||
} else {
|
||||
// Not logged in — show auth options
|
||||
console.log(`\n ${bold("claudemesh")} — sign in to connect your terminal`);
|
||||
console.log("");
|
||||
console.log(` ${bold("1)")} Sign in via browser ${dim("(opens automatically)")}`);
|
||||
console.log(` ${bold("2)")} Paste a token from ${dim("claudemesh.com/token")}`);
|
||||
console.log("");
|
||||
|
||||
const choice = await prompt(" Choice [1]: ") || "1";
|
||||
|
||||
if (choice === "2") {
|
||||
return loginWithToken();
|
||||
}
|
||||
// choice === "1" → fall through to browser login
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await loginWithDeviceCode();
|
||||
console.log(` ${green(icons.check)} Signed in as ${result.user.display_name}.`);
|
||||
await syncMeshes(result.session_token);
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Login failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
}
|
||||
22
apps/cli/src/commands/logout.ts
Normal file
22
apps/cli/src/commands/logout.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { logout as doLogout } from "~/services/auth/facade.js";
|
||||
import { green, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function logout(): Promise<number> {
|
||||
try {
|
||||
const { revoked } = await doLogout();
|
||||
|
||||
if (revoked) {
|
||||
console.log(` ${green(icons.check)} Revoked session on claudemesh.com`);
|
||||
} else {
|
||||
console.log(` ${yellow(icons.warn)} Could not revoke session on claudemesh.com.`);
|
||||
console.log(` Revoke manually at https://claudemesh.com/dashboard/settings/sessions`);
|
||||
}
|
||||
console.log(` ${green(icons.check)} Removed local credentials.`);
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Logout failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
}
|
||||
9
apps/cli/src/commands/mcp.ts
Normal file
9
apps/cli/src/commands/mcp.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { startMcpServer } from "~/mcp/server.js";
|
||||
|
||||
export async function runMcp(): Promise<never> {
|
||||
await startMcpServer();
|
||||
await new Promise(() => {});
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
export { runMcp as _stub };
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* `claudemesh remember <text> [--tags tag1,tag2]` — store a memory in the mesh.
|
||||
* `claudemesh recall <query>` — search mesh memory.
|
||||
*
|
||||
* Useful for AI agents using bash when the MCP server isn't active.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
|
||||
export interface MemoryFlags {
|
||||
mesh?: string;
|
||||
tags?: string;
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export async function runRemember(flags: MemoryFlags, content: string): Promise<void> {
|
||||
const tags = flags.tags
|
||||
? flags.tags.split(",").map((t) => t.trim()).filter(Boolean)
|
||||
: undefined;
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const id = await client.remember(content, tags);
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return;
|
||||
}
|
||||
if (id) {
|
||||
console.log(`✓ Remembered (${id.slice(0, 8)})`);
|
||||
} else {
|
||||
console.error("✗ Failed to store memory");
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function runRecall(flags: MemoryFlags, query: string): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client) => {
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||
console.log(` ${m.content}`);
|
||||
console.log(dim(` ${m.rememberedBy} · ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
});
|
||||
}
|
||||
48
apps/cli/src/commands/new.ts
Normal file
48
apps/cli/src/commands/new.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { create as createMesh } from "~/services/mesh/facade.js";
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { green, dim, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function newMesh(
|
||||
name: string,
|
||||
opts: { template?: string; description?: string; json?: boolean },
|
||||
): Promise<number> {
|
||||
if (!name) {
|
||||
console.error(" Usage: claudemesh mesh create <name>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
if (!getStoredToken()) {
|
||||
console.log(dim(" Not signed in — starting login…\n"));
|
||||
const { login } = await import("./login.js");
|
||||
const loginResult = await login();
|
||||
if (loginResult !== EXIT.SUCCESS) return loginResult;
|
||||
console.log("");
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await createMesh(name, {
|
||||
template: opts.template,
|
||||
description: opts.description,
|
||||
});
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
|
||||
} else {
|
||||
console.log(`\n ${green(icons.check)} Created "${result.slug}" (id: ${result.id})`);
|
||||
console.log(` ${green(icons.check)} You're the owner`);
|
||||
console.log(` ${green(icons.check)} Joined locally`);
|
||||
console.log(`\n Share with: claudemesh mesh share\n`);
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("409") || msg.includes("already exists")) {
|
||||
console.error(` ${icons.cross} A mesh with this name already exists. Try a different name.`);
|
||||
} else {
|
||||
console.error(` ${icons.cross} Failed: ${msg}`);
|
||||
}
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* `claudemesh peers` — list connected peers in the mesh.
|
||||
*
|
||||
* Connects, fetches the peer list, prints it, disconnects.
|
||||
* Shows all meshes by default, or filter with --mesh.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { bold, dim, green, yellow } from "~/ui/styles.js";
|
||||
|
||||
export interface PeersFlags {
|
||||
mesh?: string;
|
||||
@@ -12,44 +15,60 @@ export interface PeersFlags {
|
||||
}
|
||||
|
||||
export async function runPeers(flags: PeersFlags): Promise<void> {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const green = (s: string) => (useColor ? `\x1b[32m${s}\x1b[39m` : s);
|
||||
const yellow = (s: string) => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
const config = readConfig();
|
||||
const slugs = flags.mesh ? [flags.mesh] : config.meshes.map((m) => m.slug);
|
||||
|
||||
await withMesh({ meshSlug: flags.mesh ?? null }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
if (slugs.length === 0) {
|
||||
render.err("No meshes joined.");
|
||||
render.hint("claudemesh <invite-url> # join + launch");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (flags.json) {
|
||||
console.log(JSON.stringify(peers, null, 2));
|
||||
return;
|
||||
const allJson: Array<{ mesh: string; peers: unknown[] }> = [];
|
||||
|
||||
for (const slug of slugs) {
|
||||
try {
|
||||
await withMesh({ meshSlug: slug }, async (client, mesh) => {
|
||||
const peers = await client.listPeers();
|
||||
|
||||
if (flags.json) {
|
||||
allJson.push({ mesh: mesh.slug, peers });
|
||||
return;
|
||||
}
|
||||
|
||||
render.section(`peers on ${mesh.slug} (${peers.length})`);
|
||||
|
||||
if (peers.length === 0) {
|
||||
render.info(dim(" (no peers connected)"));
|
||||
return;
|
||||
}
|
||||
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" +
|
||||
p.groups
|
||||
.map((g: { name: string; role?: string }) => `@${g.name}${g.role ? `:${g.role}` : ""}`)
|
||||
.join(", ") +
|
||||
"]"
|
||||
: "";
|
||||
const statusDot = p.status === "working" ? yellow("●") : green("●");
|
||||
const name = bold(p.displayName);
|
||||
const meta: string[] = [];
|
||||
if (p.peerType) meta.push(p.peerType);
|
||||
if (p.channel) meta.push(p.channel);
|
||||
if (p.model) meta.push(p.model);
|
||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||
const summary = p.summary ? dim(` — ${p.summary}`) : "";
|
||||
render.info(`${statusDot} ${name}${groups}${metaStr}${summary}`);
|
||||
if (p.cwd) render.info(dim(` cwd: ${p.cwd}`));
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
render.err(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (peers.length === 0) {
|
||||
console.log(dim(`No peers connected on mesh "${mesh.slug}".`));
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(bold(`Peers on ${mesh.slug}`) + dim(` (${peers.length})`));
|
||||
console.log("");
|
||||
for (const p of peers) {
|
||||
const groups = p.groups.length
|
||||
? " [" + p.groups.map((g) => `@${g.name}${g.role ? `:${g.role}` : ""}`).join(", ") + "]"
|
||||
: "";
|
||||
const statusIcon = p.status === "working" ? yellow("●") : green("●");
|
||||
const name = bold(p.displayName);
|
||||
const meta: string[] = [];
|
||||
if (p.peerType) meta.push(p.peerType);
|
||||
if (p.channel) meta.push(p.channel);
|
||||
if (p.model) meta.push(p.model);
|
||||
const metaStr = meta.length ? dim(` (${meta.join(", ")})`) : "";
|
||||
const cwdStr = p.cwd ? dim(` cwd: ${p.cwd}`) : "";
|
||||
const summary = p.summary ? dim(` ${p.summary}`) : "";
|
||||
console.log(` ${statusIcon} ${name}${groups}${metaStr}${summary}`);
|
||||
if (cwdStr) console.log(` ${cwdStr}`);
|
||||
}
|
||||
console.log("");
|
||||
});
|
||||
if (flags.json) {
|
||||
process.stdout.write(JSON.stringify(slugs.length === 1 ? allJson[0]?.peers : allJson, null, 2) + "\n");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
* on the server. Changes are pushed to active sessions in real-time.
|
||||
*/
|
||||
|
||||
import { loadConfig } from "../state/config";
|
||||
import { BrokerClient } from "../ws/client";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
|
||||
export interface ProfileFlags {
|
||||
mesh?: string;
|
||||
@@ -23,7 +23,7 @@ export async function runProfile(flags: ProfileFlags): Promise<void> {
|
||||
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 config = loadConfig();
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.error("No meshes joined. Run `claudemesh join <url>` first.");
|
||||
process.exit(1);
|
||||
|
||||
35
apps/cli/src/commands/recall.ts
Normal file
35
apps/cli/src/commands/recall.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { allClients } from "~/services/broker/facade.js";
|
||||
import { dim, bold } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function recall(
|
||||
query: string,
|
||||
opts: { mesh?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const client = allClients()[0];
|
||||
if (!client) {
|
||||
console.error("Not connected to any mesh.");
|
||||
return EXIT.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
const memories = await client.recall(query);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(memories, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (memories.length === 0) {
|
||||
console.log(dim("No memories found."));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
for (const m of memories) {
|
||||
const tags = m.tags.length ? dim(` [${m.tags.join(", ")}]`) : "";
|
||||
console.log(`${bold(m.id.slice(0, 8))}${tags}`);
|
||||
console.log(` ${m.content}`);
|
||||
console.log(dim(` ${m.rememberedBy} \u00B7 ${new Date(m.rememberedAt).toLocaleString()}`));
|
||||
console.log("");
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
8
apps/cli/src/commands/register.ts
Normal file
8
apps/cli/src/commands/register.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
import { login } from "./login.js";
|
||||
|
||||
// Register and login use the same device-code flow.
|
||||
// The browser page (/cli-auth) redirects to /auth/login if not authenticated,
|
||||
// which has a "Don't have an account? Register" link.
|
||||
export async function register(): Promise<number> {
|
||||
return login();
|
||||
}
|
||||
28
apps/cli/src/commands/remember.ts
Normal file
28
apps/cli/src/commands/remember.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { allClients } from "~/services/broker/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function remember(
|
||||
content: string,
|
||||
opts: { mesh?: string; tags?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const client = allClients()[0];
|
||||
if (!client) {
|
||||
console.error("Not connected to any mesh.");
|
||||
return EXIT.NETWORK_ERROR;
|
||||
}
|
||||
|
||||
const tags = opts.tags?.split(",").map((t) => t.trim()).filter(Boolean);
|
||||
const id = await client.remember(content, tags);
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ id, content, tags }));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (id) {
|
||||
console.log(`\u2713 Remembered (${id.slice(0, 8)})`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
console.error("\u2717 Failed to store memory");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
* Human-facing interface to the broker's scheduled message delivery.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import { withMesh } from "./connect.js";
|
||||
|
||||
export interface RemindFlags {
|
||||
mesh?: string;
|
||||
|
||||
14
apps/cli/src/commands/rename.ts
Normal file
14
apps/cli/src/commands/rename.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { rename as renameMesh } from "~/services/mesh/facade.js";
|
||||
import { green, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function rename(slug: string, newName: string): Promise<number> {
|
||||
try {
|
||||
await renameMesh(slug, newName);
|
||||
console.log(` ${green(icons.check)} Renamed "${slug}" to "${newName}"`);
|
||||
return EXIT.SUCCESS;
|
||||
} catch (err) {
|
||||
console.error(` ${icons.cross} Failed: ${err instanceof Error ? err.message : err}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@
|
||||
* claudemesh seed-test-mesh <broker-url> <mesh-id> <member-id> <pubkey> <slug>
|
||||
*/
|
||||
|
||||
import { loadConfig, saveConfig } from "../state/config";
|
||||
import { readConfig, writeConfig } from "~/services/config/facade.js";
|
||||
|
||||
export function runSeedTestMesh(args: string[]): void {
|
||||
const [brokerUrl, meshId, memberId, pubkey, slug] = args;
|
||||
@@ -23,7 +23,7 @@ export function runSeedTestMesh(args: string[]): void {
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
// Remove any prior entry with same slug (idempotent).
|
||||
config.meshes = config.meshes.filter((m) => m.slug !== slug);
|
||||
config.meshes.push({
|
||||
@@ -36,7 +36,7 @@ export function runSeedTestMesh(args: string[]): void {
|
||||
brokerUrl,
|
||||
joinedAt: new Date().toISOString(),
|
||||
});
|
||||
saveConfig(config);
|
||||
writeConfig(config);
|
||||
console.log(`Seeded mesh "${slug}" (${meshId}) into local config.`);
|
||||
console.log(
|
||||
`Run \`claudemesh mcp\` to connect, or register with Claude Code via \`claudemesh install\`.`,
|
||||
|
||||
@@ -8,8 +8,8 @@
|
||||
* - * (broadcast to all)
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import type { Priority } from "../ws/client";
|
||||
import { withMesh } from "./connect.js";
|
||||
import type { Priority } from "~/services/broker/facade.js";
|
||||
|
||||
export interface SendFlags {
|
||||
mesh?: string;
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
* `claudemesh state list` — list all state entries
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect";
|
||||
import { withMesh } from "./connect.js";
|
||||
|
||||
export interface StateFlags {
|
||||
mesh?: string;
|
||||
|
||||
69
apps/cli/src/commands/status-line.ts
Normal file
69
apps/cli/src/commands/status-line.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* `claudemesh status-line` — one-line renderer for Claude Code's
|
||||
* `statusLine` setting.
|
||||
*
|
||||
* Must be FAST (Claude Code polls it between every turn) — zero network
|
||||
* I/O. Reads only local config + a peer-state cache maintained by the
|
||||
* MCP server (~/.claudemesh/peer-cache.json, updated on every
|
||||
* list_peers call).
|
||||
*
|
||||
* Output format:
|
||||
* ◇ <mesh> · <online>/<total> peers · <you>
|
||||
* or:
|
||||
* ◇ claudemesh (not joined)
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync, statSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { homedir } from "node:os";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
interface PeerCacheEntry {
|
||||
total: number;
|
||||
online: number;
|
||||
updatedAt: string;
|
||||
you?: string;
|
||||
}
|
||||
|
||||
type PeerCache = Record<string, PeerCacheEntry>;
|
||||
|
||||
export async function runStatusLine(): Promise<number> {
|
||||
try {
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
process.stdout.write("◇ claudemesh (not joined)");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
const cachePath = join(homedir(), ".claudemesh", "peer-cache.json");
|
||||
let cache: PeerCache = {};
|
||||
if (existsSync(cachePath)) {
|
||||
try {
|
||||
cache = JSON.parse(readFileSync(cachePath, "utf-8")) as PeerCache;
|
||||
} catch {
|
||||
// corrupt — ignore
|
||||
}
|
||||
}
|
||||
|
||||
// Pick the most-recently-used mesh if multiple.
|
||||
const pick = config.meshes[0]!;
|
||||
const entry = cache[pick.slug];
|
||||
|
||||
const age = entry ? Date.now() - new Date(entry.updatedAt).getTime() : Infinity;
|
||||
const fresh = age < 60_000; // < 1 min = live
|
||||
|
||||
if (entry && fresh) {
|
||||
const you = entry.you ? ` · ${entry.you}` : "";
|
||||
process.stdout.write(`◇ ${pick.slug} · ${entry.online}/${entry.total} online${you}`);
|
||||
} else if (entry) {
|
||||
process.stdout.write(`◇ ${pick.slug} · ${entry.online}/${entry.total} (stale)`);
|
||||
} else {
|
||||
process.stdout.write(`◇ ${pick.slug} · idle`);
|
||||
}
|
||||
return EXIT.SUCCESS;
|
||||
} catch {
|
||||
// Never break the status line — just print nothing.
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
}
|
||||
@@ -8,8 +8,9 @@
|
||||
|
||||
import { statSync, existsSync } from "node:fs";
|
||||
import WebSocket from "ws";
|
||||
import { loadConfig, getConfigPath } from "../state/config";
|
||||
import { VERSION } from "../version";
|
||||
import { readConfig, getConfigPath } from "~/services/config/facade.js";
|
||||
import { VERSION } from "~/constants/urls.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
|
||||
interface MeshStatus {
|
||||
slug: string;
|
||||
@@ -17,10 +18,12 @@ interface MeshStatus {
|
||||
pubkey: string;
|
||||
reachable: boolean;
|
||||
error?: string;
|
||||
latencyMs?: number;
|
||||
}
|
||||
|
||||
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string }> {
|
||||
async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean; error?: string; latencyMs?: number }> {
|
||||
return new Promise((resolve) => {
|
||||
const started = Date.now();
|
||||
const ws = new WebSocket(url);
|
||||
const timer = setTimeout(() => {
|
||||
try { ws.terminate(); } catch { /* noop */ }
|
||||
@@ -28,8 +31,9 @@ async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean
|
||||
}, timeoutMs);
|
||||
ws.on("open", () => {
|
||||
clearTimeout(timer);
|
||||
const latency = Date.now() - started;
|
||||
try { ws.close(); } catch { /* noop */ }
|
||||
resolve({ ok: true });
|
||||
resolve({ ok: true, latencyMs: latency });
|
||||
});
|
||||
ws.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
@@ -39,65 +43,59 @@ async function probeBroker(url: string, timeoutMs = 4000): Promise<{ ok: boolean
|
||||
}
|
||||
|
||||
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));
|
||||
render.section(`status (v${VERSION})`);
|
||||
|
||||
const configPath = getConfigPath();
|
||||
let configPerms = "missing";
|
||||
let configPermsNote = "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)`;
|
||||
const mode = (statSync(configPath).mode & 0o777).toString(8).padStart(4, "0");
|
||||
configPermsNote = mode === "0600" ? `${mode}` : `${mode} — expected 0600`;
|
||||
}
|
||||
console.log(`Config: ${configPath} (${configPerms})`);
|
||||
render.kv([["config", configPath], ["perms", configPermsNote]]);
|
||||
|
||||
const config = loadConfig();
|
||||
const config = readConfig();
|
||||
if (config.meshes.length === 0) {
|
||||
console.log("");
|
||||
console.log(dim("No meshes joined. Run `claudemesh join <invite-url>` to get started."));
|
||||
render.blank();
|
||||
render.info("No meshes joined.");
|
||||
render.hint("claudemesh <invite-url> # join + launch");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(`Meshes (${config.meshes.length}):`);
|
||||
render.blank();
|
||||
render.heading(`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({
|
||||
const entry: MeshStatus = {
|
||||
slug: m.slug,
|
||||
brokerUrl: m.brokerUrl,
|
||||
pubkey: m.pubkey,
|
||||
reachable: probe.ok,
|
||||
error: probe.error,
|
||||
});
|
||||
latencyMs: probe.latencyMs,
|
||||
};
|
||||
results.push(entry);
|
||||
if (probe.ok) {
|
||||
console.log(green("reachable"));
|
||||
render.ok(`${m.slug}`, `${probe.latencyMs}ms → ${m.brokerUrl}`);
|
||||
} else {
|
||||
console.log(red(`unreachable (${probe.error})`));
|
||||
render.err(`${m.slug}`, `unreachable (${probe.error})`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("");
|
||||
render.blank();
|
||||
for (const r of results) {
|
||||
console.log(dim(` ${r.slug}: pubkey ${r.pubkey.slice(0, 16)}…`));
|
||||
render.kv([[r.slug, `${r.pubkey.slice(0, 16)}…`]]);
|
||||
}
|
||||
|
||||
const allOk = results.every((r) => r.reachable);
|
||||
console.log("");
|
||||
render.blank();
|
||||
if (allOk) {
|
||||
console.log(green("All meshes reachable."));
|
||||
render.ok("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.`));
|
||||
render.err(`${broken} of ${results.length} mesh(es) unreachable`);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
|
||||
import { createInterface } from "node:readline";
|
||||
import { hostname } from "node:os";
|
||||
import { loadConfig, saveConfig } from "../state/config";
|
||||
import { startCallbackListener, openBrowser, generatePairingCode, syncWithBroker } from "../auth";
|
||||
import { generateKeypair } from "../crypto/keypair";
|
||||
import { readConfig, writeConfig } from "~/services/config/facade.js";
|
||||
import { startCallbackListener, generatePairingCode, syncWithBroker } from "~/services/auth/facade.js";
|
||||
import { openBrowser } from "~/services/spawn/facade.js";
|
||||
import { generateKeypair } from "~/services/crypto/facade.js";
|
||||
|
||||
export async function runSync(args: { force?: boolean }): 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 config = loadConfig();
|
||||
const config = readConfig();
|
||||
|
||||
const code = generatePairingCode();
|
||||
const listener = await startCallbackListener();
|
||||
@@ -78,7 +79,7 @@ export async function runSync(args: { force?: boolean }): Promise<void> {
|
||||
added++;
|
||||
}
|
||||
config.accountId = result.account_id;
|
||||
saveConfig(config);
|
||||
writeConfig(config);
|
||||
|
||||
if (added > 0) {
|
||||
console.log(green(`✓ Added ${added} new mesh(es)`));
|
||||
|
||||
228
apps/cli/src/commands/test.ts
Normal file
228
apps/cli/src/commands/test.ts
Normal file
@@ -0,0 +1,228 @@
|
||||
/**
|
||||
* `claudemesh test` — integration test battery against live broker.
|
||||
*
|
||||
* Creates a temporary mesh, runs all operations, verifies results,
|
||||
* then cleans up. Safe to run repeatedly.
|
||||
*/
|
||||
|
||||
import { getStoredToken } from "~/services/auth/facade.js";
|
||||
import { create as createMesh, leave as leaveMesh } from "~/services/mesh/facade.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { request } from "~/services/api/facade.js";
|
||||
import { generateKeypair, sign, verify } from "~/services/crypto/facade.js";
|
||||
import { BrokerClient } from "~/services/broker/facade.js";
|
||||
import { URLS } from "~/constants/urls.js";
|
||||
import { runAllChecks } from "~/services/health/facade.js";
|
||||
import { green, red, dim, bold, yellow, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
const BROKER_HTTP = URLS.BROKER.replace("wss://", "https://").replace("ws://", "http://").replace("/ws", "");
|
||||
|
||||
interface TestResult {
|
||||
name: string;
|
||||
ok: boolean;
|
||||
detail: string;
|
||||
ms: number;
|
||||
}
|
||||
|
||||
const results: TestResult[] = [];
|
||||
|
||||
async function run(name: string, fn: () => Promise<string>): Promise<boolean> {
|
||||
const start = Date.now();
|
||||
try {
|
||||
const detail = await fn();
|
||||
results.push({ name, ok: true, detail, ms: Date.now() - start });
|
||||
console.log(` ${green(icons.check)} ${name.padEnd(18)} ${dim(detail)}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
const detail = err instanceof Error ? err.message : String(err);
|
||||
results.push({ name, ok: false, detail, ms: Date.now() - start });
|
||||
console.log(` ${red(icons.cross)} ${name.padEnd(18)} ${red(detail)}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function runTest(): Promise<number> {
|
||||
const started = Date.now();
|
||||
const meshSlug = `test-e2e-${Date.now().toString(36)}`;
|
||||
|
||||
console.log("");
|
||||
console.log(` ${bold("claudemesh integration test")}`);
|
||||
console.log(` ${dim("─".repeat(40))}`);
|
||||
console.log("");
|
||||
|
||||
// --- Auth ---
|
||||
const auth = getStoredToken();
|
||||
if (!auth) {
|
||||
console.log(` ${red(icons.cross)} Not signed in. Run ${bold("claudemesh login")} first.\n`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
let userId = "";
|
||||
try {
|
||||
const payload = JSON.parse(Buffer.from(auth.session_token.split(".")[1]!, "base64url").toString()) as { sub?: string };
|
||||
userId = payload.sub ?? "";
|
||||
} catch {}
|
||||
|
||||
await run("auth", async () => {
|
||||
if (!userId) throw new Error("invalid token");
|
||||
return `signed in as ${auth.user.display_name || auth.user.email}`;
|
||||
});
|
||||
|
||||
// --- Doctor checks (non-blocking — warns but doesn't fail) ---
|
||||
{
|
||||
const checks = runAllChecks();
|
||||
const failed = checks.filter(c => !c.ok);
|
||||
if (failed.length > 0) {
|
||||
const warns = failed.map(c => c.name).join(", ");
|
||||
console.log(` ${yellow(icons.warn)} ${"doctor".padEnd(18)} ${dim(warns + " (non-blocking)")}`);
|
||||
} else {
|
||||
console.log(` ${green(icons.check)} ${"doctor".padEnd(18)} ${dim(checks.length + " checks passed")}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- Crypto ---
|
||||
await run("crypto", async () => {
|
||||
const kp = await generateKeypair();
|
||||
const sig = await sign("test-message", kp.secretKey);
|
||||
const valid = await verify("test-message", sig, kp.publicKey);
|
||||
if (!valid) throw new Error("signature verification failed");
|
||||
const tampered = await verify("tampered", sig, kp.publicKey);
|
||||
if (tampered) throw new Error("tampered message should not verify");
|
||||
return "keypair + sign + verify round-trip";
|
||||
});
|
||||
|
||||
// --- Mesh create ---
|
||||
let meshId = "";
|
||||
const createOk = await run("create", async () => {
|
||||
const result = await createMesh(meshSlug);
|
||||
meshId = result.id;
|
||||
return `created "${result.slug}" (${result.id.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
if (!createOk) {
|
||||
console.log(`\n ${red("Aborting — mesh creation failed.")}\n`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
await run("list", async () => {
|
||||
const config = readConfig();
|
||||
const found = config.meshes.find(m => m.slug === meshSlug);
|
||||
if (!found) throw new Error("mesh not in local config");
|
||||
return `found ${meshSlug} in local config`;
|
||||
});
|
||||
|
||||
// --- Server list ---
|
||||
await run("server list", async () => {
|
||||
const res = await request<{ meshes: Array<{ slug: string }> }>({
|
||||
path: `/cli/meshes?user_id=${userId}`,
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
const found = res.meshes?.find(m => m.slug === meshSlug);
|
||||
if (!found) throw new Error("mesh not on server");
|
||||
return `found ${meshSlug} on server (${res.meshes.length} total)`;
|
||||
});
|
||||
|
||||
// --- Connect (broker WS) ---
|
||||
const config = readConfig();
|
||||
const meshConfig = config.meshes.find(m => m.slug === meshSlug);
|
||||
let client: BrokerClient | null = null;
|
||||
|
||||
if (meshConfig) {
|
||||
await run("connect", async () => {
|
||||
client = new BrokerClient(meshConfig, { displayName: "test-runner" });
|
||||
await client.connect();
|
||||
if (client.status !== "open") throw new Error("status: " + client.status);
|
||||
return "broker connected, hello_ack received";
|
||||
});
|
||||
|
||||
// --- Peers ---
|
||||
if (client) {
|
||||
await run("peers", async () => {
|
||||
const peers = await client!.listPeers();
|
||||
return `${peers.length} peer(s) online`;
|
||||
});
|
||||
|
||||
// --- Send ---
|
||||
await run("send", async () => {
|
||||
const result = await client!.send("*", "test-battery-ping", "low");
|
||||
if (!result.ok) throw new Error(result.error ?? "send failed");
|
||||
return `broadcast sent (${result.messageId?.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
// --- Remember ---
|
||||
let memoryId: string | null = null;
|
||||
await run("remember", async () => {
|
||||
memoryId = await client!.remember("integration test battery memory probe", ["test", "e2e"]);
|
||||
if (!memoryId) throw new Error("no memory ID returned");
|
||||
return `stored (${memoryId.slice(0, 8)}…)`;
|
||||
});
|
||||
|
||||
// --- Recall (postgres full-text search) ---
|
||||
await run("recall", async () => {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const memories = await client!.recall("integration test battery");
|
||||
if (memories.length === 0) throw new Error("no memories found");
|
||||
return `${memories.length} result(s)`;
|
||||
});
|
||||
|
||||
// --- State ---
|
||||
const stateVal = "test-value-" + Date.now();
|
||||
await run("state set", async () => {
|
||||
await client!.setState("test-e2e-key", stateVal);
|
||||
return "key written";
|
||||
});
|
||||
|
||||
await run("state get", async () => {
|
||||
await new Promise(r => setTimeout(r, 500));
|
||||
const result = await client!.getState("test-e2e-key");
|
||||
if (!result) throw new Error("key not found");
|
||||
if (String(result.value) !== stateVal) throw new Error(`expected ${stateVal}, got ${result.value}`);
|
||||
return `read back: ${String(result.value).slice(0, 20)}…`;
|
||||
});
|
||||
|
||||
// --- Clean up memory ---
|
||||
if (memoryId) {
|
||||
await run("forget", async () => {
|
||||
await client!.forget(memoryId!);
|
||||
return "memory cleaned up";
|
||||
});
|
||||
}
|
||||
|
||||
// --- Disconnect ---
|
||||
await run("disconnect", async () => {
|
||||
client!.close();
|
||||
return "connection closed";
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Delete mesh ---
|
||||
await run("delete", async () => {
|
||||
// Server-side delete
|
||||
await request({
|
||||
path: `/cli/mesh/${meshSlug}`,
|
||||
method: "DELETE",
|
||||
body: { user_id: userId },
|
||||
baseUrl: BROKER_HTTP,
|
||||
});
|
||||
leaveMesh(meshSlug);
|
||||
return `deleted "${meshSlug}" from server + local`;
|
||||
});
|
||||
|
||||
// --- Summary ---
|
||||
const passed = results.filter(r => r.ok).length;
|
||||
const failed = results.filter(r => !r.ok).length;
|
||||
const totalMs = Date.now() - started;
|
||||
|
||||
console.log("");
|
||||
if (failed === 0) {
|
||||
console.log(` ${green(bold(`${passed}/${results.length} passed`))} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
|
||||
} else {
|
||||
console.log(` ${red(bold(`${failed} failed`))}, ${green(`${passed} passed`)} ${dim(`(${(totalMs / 1000).toFixed(1)}s)`)}`);
|
||||
}
|
||||
console.log("");
|
||||
|
||||
return failed > 0 ? EXIT.INTERNAL_ERROR : EXIT.SUCCESS;
|
||||
}
|
||||
58
apps/cli/src/commands/uninstall.ts
Normal file
58
apps/cli/src/commands/uninstall.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
||||
import { PATHS } from "~/constants/paths.js";
|
||||
import { green, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function uninstall(): Promise<number> {
|
||||
let removed = 0;
|
||||
|
||||
// Remove MCP server from ~/.claude.json
|
||||
if (existsSync(PATHS.CLAUDE_JSON)) {
|
||||
try {
|
||||
const raw = readFileSync(PATHS.CLAUDE_JSON, "utf-8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
const servers = config.mcpServers as Record<string, unknown> | undefined;
|
||||
if (servers && "claudemesh" in servers) {
|
||||
delete servers.claudemesh;
|
||||
writeFileSync(PATHS.CLAUDE_JSON, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
console.log(` ${green(icons.check)} Removed MCP server from ~/.claude.json`);
|
||||
removed++;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// Remove only claudemesh hooks from ~/.claude/settings.json
|
||||
if (existsSync(PATHS.CLAUDE_SETTINGS)) {
|
||||
try {
|
||||
const raw = readFileSync(PATHS.CLAUDE_SETTINGS, "utf-8");
|
||||
const config = JSON.parse(raw) as Record<string, unknown>;
|
||||
const hooks = config.hooks as Record<string, unknown[]> | undefined;
|
||||
if (hooks) {
|
||||
let removedHooks = 0;
|
||||
for (const [event, entries] of Object.entries(hooks)) {
|
||||
if (!Array.isArray(entries)) continue;
|
||||
const filtered = entries.filter((h: unknown) => {
|
||||
const cmd = typeof h === "object" && h !== null && "command" in h ? String((h as Record<string, unknown>).command) : "";
|
||||
return !cmd.includes("claudemesh");
|
||||
});
|
||||
if (filtered.length < entries.length) {
|
||||
removedHooks += entries.length - filtered.length;
|
||||
if (filtered.length === 0) delete hooks[event];
|
||||
else hooks[event] = filtered;
|
||||
}
|
||||
}
|
||||
if (removedHooks > 0) {
|
||||
writeFileSync(PATHS.CLAUDE_SETTINGS, JSON.stringify(config, null, 2) + "\n", "utf-8");
|
||||
console.log(` ${green(icons.check)} Removed ${removedHooks} claudemesh hook(s) from settings.json`);
|
||||
removed++;
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
if (removed === 0) {
|
||||
console.log(" Nothing to remove — claudemesh was not installed.");
|
||||
}
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
99
apps/cli/src/commands/upgrade.ts
Normal file
99
apps/cli/src/commands/upgrade.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* `claudemesh upgrade` — self-update the CLI to the latest alpha.
|
||||
*
|
||||
* Strategy:
|
||||
* 1. Query npm for the latest @alpha dist-tag.
|
||||
* 2. If we're behind, run `npm i -g claudemesh-cli@alpha` via the same
|
||||
* npm that installed us (detected from argv[1] path walk).
|
||||
* 3. Print before/after versions.
|
||||
*
|
||||
* For users who got the CLI via the `/install` shell flow (portable Node
|
||||
* in ~/.claudemesh), we call that npm directly so nothing else on the
|
||||
* system is touched.
|
||||
*/
|
||||
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { URLS, VERSION } from "~/constants/urls.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
async function latestAlpha(): Promise<string | null> {
|
||||
try {
|
||||
const res = await fetch(URLS.NPM_REGISTRY, { signal: AbortSignal.timeout(8000) });
|
||||
if (!res.ok) return null;
|
||||
const body = (await res.json()) as { "dist-tags"?: { alpha?: string; latest?: string } };
|
||||
return body["dist-tags"]?.alpha ?? body["dist-tags"]?.latest ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findNpm(): { npm: string; prefix?: string } {
|
||||
// Portable install path (`/install.sh` puts npm in ~/.claudemesh/node/bin/npm)
|
||||
const portable = join(process.env.HOME ?? "", ".claudemesh", "node", "bin", "npm");
|
||||
if (existsSync(portable)) {
|
||||
return { npm: portable, prefix: join(process.env.HOME ?? "", ".claudemesh") };
|
||||
}
|
||||
// argv[1] → .../node_modules/claudemesh-cli/dist/entrypoints/cli.js
|
||||
// walk up to find a sibling npm binary.
|
||||
let cur = resolve(process.argv[1] ?? ".");
|
||||
for (let i = 0; i < 6; i++) {
|
||||
cur = dirname(cur);
|
||||
const candidate = join(cur, "bin", "npm");
|
||||
if (existsSync(candidate)) return { npm: candidate };
|
||||
}
|
||||
// Fallback to PATH.
|
||||
return { npm: "npm" };
|
||||
}
|
||||
|
||||
export async function runUpgrade(opts: { check?: boolean; yes?: boolean } = {}): Promise<number> {
|
||||
render.section("claudemesh upgrade");
|
||||
render.kv([
|
||||
["installed", VERSION],
|
||||
["checking", "npm registry…"],
|
||||
]);
|
||||
|
||||
const latest = await latestAlpha();
|
||||
if (!latest) {
|
||||
render.warn("Could not reach npm registry — skipped.");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
render.kv([["latest", latest]]);
|
||||
|
||||
if (latest === VERSION) {
|
||||
render.blank();
|
||||
render.ok(`Already on latest (${latest}).`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (opts.check) {
|
||||
render.blank();
|
||||
render.warn(`Update available: ${VERSION} → ${latest}`);
|
||||
render.hint("Run: claudemesh upgrade");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
const { npm, prefix } = findNpm();
|
||||
const args = ["install", "-g"];
|
||||
if (prefix) args.push("--prefix", prefix);
|
||||
args.push("claudemesh-cli@alpha");
|
||||
|
||||
render.blank();
|
||||
render.info(`Updating ${VERSION} → ${latest}…`);
|
||||
render.hint(`${npm} ${args.join(" ")}`);
|
||||
render.blank();
|
||||
|
||||
const res = spawnSync(npm, args, { stdio: "inherit" });
|
||||
if (res.status !== 0) {
|
||||
render.err(`npm exited with status ${res.status}`);
|
||||
render.hint("Try: npm i -g claudemesh-cli@alpha");
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
|
||||
render.blank();
|
||||
render.ok(`Upgraded to ${latest}.`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
178
apps/cli/src/commands/url-handler.ts
Normal file
178
apps/cli/src/commands/url-handler.ts
Normal file
@@ -0,0 +1,178 @@
|
||||
/**
|
||||
* `claudemesh url-handler <install|uninstall>` — register a `claudemesh://`
|
||||
* URL scheme handler with the OS so click-to-launch from email/web works.
|
||||
*
|
||||
* Scheme: `claudemesh://join/<code-or-token>` or `claudemesh://i/<code>`.
|
||||
* When activated, the OS opens the handler, which runs
|
||||
* claudemesh https://claudemesh.com/i/<code>
|
||||
* (inline join + launch path via the bare-URL dispatch in cli.ts).
|
||||
*
|
||||
* Platforms:
|
||||
* - darwin → LSRegisterURL via a per-user .app bundle in
|
||||
* ~/Library/Application\ Support/claudemesh/ClaudemeshHandler.app
|
||||
* - linux → xdg-mime default + a .desktop file in
|
||||
* ~/.local/share/applications/claudemesh.desktop
|
||||
* - win32 → HKCU\Software\Classes\claudemesh (registry write)
|
||||
*/
|
||||
|
||||
import { platform, homedir } from "node:os";
|
||||
import { existsSync, mkdirSync, writeFileSync, rmSync, chmodSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
function resolveClaudemeshBin(): string {
|
||||
// argv[1] points to the running binary; prefer that over $PATH so we
|
||||
// register the exact install the user ran.
|
||||
return process.argv[1] ?? "claudemesh";
|
||||
}
|
||||
|
||||
function installDarwin(): number {
|
||||
const binPath = resolveClaudemeshBin();
|
||||
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
|
||||
const contents = join(appDir, "Contents");
|
||||
const macOS = join(contents, "MacOS");
|
||||
mkdirSync(macOS, { recursive: true });
|
||||
|
||||
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleIdentifier</key><string>com.claudemesh.handler</string>
|
||||
<key>CFBundleName</key><string>Claudemesh</string>
|
||||
<key>CFBundleExecutable</key><string>open-url</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
||||
<key>CFBundlePackageType</key><string>APPL</string>
|
||||
<key>CFBundleSignature</key><string>????</string>
|
||||
<key>CFBundleShortVersionString</key><string>1.0</string>
|
||||
<key>LSUIElement</key><true/>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key><string>Claudemesh Invite</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array><string>claudemesh</string></array>
|
||||
</dict>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>`;
|
||||
writeFileSync(join(contents, "Info.plist"), plist);
|
||||
|
||||
// Tiny shell shim: parse the URL and re-invoke the CLI in a Terminal
|
||||
// window so the user sees launch output.
|
||||
const shim = `#!/bin/sh
|
||||
URL="$1"
|
||||
CODE=\${URL#claudemesh://}
|
||||
CODE=\${CODE#i/}
|
||||
CODE=\${CODE#join/}
|
||||
# Open a Terminal window so the user can see claude launching
|
||||
osascript <<EOF
|
||||
tell application "Terminal"
|
||||
activate
|
||||
do script "${binPath.replace(/"/g, '\\"')} https://claudemesh.com/i/$CODE"
|
||||
end tell
|
||||
EOF
|
||||
`;
|
||||
const shimPath = join(macOS, "open-url");
|
||||
writeFileSync(shimPath, shim);
|
||||
chmodSync(shimPath, 0o755);
|
||||
|
||||
// Re-register with Launch Services so the scheme resolves here.
|
||||
const lsreg = spawnSync("/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister", ["-f", appDir], { encoding: "utf-8" });
|
||||
if (lsreg.status !== 0) {
|
||||
console.log(" ⚠ lsregister returned non-zero; scheme may not activate until Finder rescans.");
|
||||
}
|
||||
console.log(` ✓ Registered claudemesh:// scheme on macOS`);
|
||||
console.log(` app bundle: ${appDir}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function installLinux(): number {
|
||||
const binPath = resolveClaudemeshBin();
|
||||
const appsDir = join(homedir(), ".local", "share", "applications");
|
||||
mkdirSync(appsDir, { recursive: true });
|
||||
const desktop = `[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Claudemesh
|
||||
Comment=Claudemesh invite handler
|
||||
Exec=${binPath} %u
|
||||
StartupNotify=false
|
||||
Terminal=true
|
||||
MimeType=x-scheme-handler/claudemesh;
|
||||
NoDisplay=true
|
||||
`;
|
||||
const desktopPath = join(appsDir, "claudemesh.desktop");
|
||||
writeFileSync(desktopPath, desktop);
|
||||
|
||||
const xdg1 = spawnSync("xdg-mime", ["default", "claudemesh.desktop", "x-scheme-handler/claudemesh"], { encoding: "utf-8" });
|
||||
if (xdg1.status !== 0) {
|
||||
console.log(" ⚠ xdg-mime not available — skipped mime default registration");
|
||||
}
|
||||
const xdg2 = spawnSync("update-desktop-database", [appsDir], { encoding: "utf-8" });
|
||||
xdg2.status ?? 0; // best effort
|
||||
console.log(` ✓ Registered claudemesh:// scheme on Linux`);
|
||||
console.log(` desktop entry: ${desktopPath}`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function installWindows(): number {
|
||||
const binPath = resolveClaudemeshBin().replace(/\//g, "\\");
|
||||
const lines = [
|
||||
`Windows Registry Editor Version 5.00`,
|
||||
``,
|
||||
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh]`,
|
||||
`@="URL:Claudemesh Invite"`,
|
||||
`"URL Protocol"=""`,
|
||||
``,
|
||||
`[HKEY_CURRENT_USER\\Software\\Classes\\claudemesh\\shell\\open\\command]`,
|
||||
`@="\\"${binPath.replace(/\\/g, "\\\\")}\\" \\"%1\\""`,
|
||||
];
|
||||
const regPath = join(homedir(), "claudemesh-handler.reg");
|
||||
writeFileSync(regPath, lines.join("\r\n"));
|
||||
const res = spawnSync("reg.exe", ["import", regPath], { encoding: "utf-8" });
|
||||
if (res.status !== 0) {
|
||||
console.log(` ⚠ reg.exe import failed. Manual: double-click ${regPath}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
console.log(` ✓ Registered claudemesh:// scheme on Windows`);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallDarwin(): number {
|
||||
const appDir = join(homedir(), "Library", "Application Support", "claudemesh", "ClaudemeshHandler.app");
|
||||
if (existsSync(appDir)) rmSync(appDir, { recursive: true, force: true });
|
||||
console.log(" ✓ Removed claudemesh:// handler on macOS");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallLinux(): number {
|
||||
const desktopPath = join(homedir(), ".local", "share", "applications", "claudemesh.desktop");
|
||||
if (existsSync(desktopPath)) rmSync(desktopPath, { force: true });
|
||||
console.log(" ✓ Removed claudemesh:// handler on Linux");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
function uninstallWindows(): number {
|
||||
spawnSync("reg.exe", ["delete", "HKCU\\Software\\Classes\\claudemesh", "/f"], { encoding: "utf-8" });
|
||||
console.log(" ✓ Removed claudemesh:// handler on Windows");
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
export async function runUrlHandler(action: string | undefined): Promise<number> {
|
||||
const act = action ?? "install";
|
||||
const p = platform();
|
||||
if (act === "install") {
|
||||
if (p === "darwin") return installDarwin();
|
||||
if (p === "linux") return installLinux();
|
||||
if (p === "win32") return installWindows();
|
||||
} else if (act === "uninstall" || act === "remove") {
|
||||
if (p === "darwin") return uninstallDarwin();
|
||||
if (p === "linux") return uninstallLinux();
|
||||
if (p === "win32") return uninstallWindows();
|
||||
} else {
|
||||
console.error("Usage: claudemesh url-handler <install|uninstall>");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
console.error(`Unsupported platform: ${p}`);
|
||||
return EXIT.INTERNAL_ERROR;
|
||||
}
|
||||
95
apps/cli/src/commands/verify.ts
Normal file
95
apps/cli/src/commands/verify.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* `claudemesh verify [peer]` — show safety numbers for a peer.
|
||||
*
|
||||
* A safety number is a derived, human-readable fingerprint of the peer's
|
||||
* ed25519 public key plus your own. Both parties see the same digits,
|
||||
* so out-of-band comparison (call, in-person) detects MITM.
|
||||
*
|
||||
* Format: 6 groups of 5 decimal digits. Rendered from the first 15 bytes
|
||||
* of SHA-256(sorted(your_pubkey ++ peer_pubkey)). Matches the Signal /
|
||||
* Whatsapp pattern so users don't have to learn a new mental model.
|
||||
*/
|
||||
|
||||
import { withMesh } from "./connect.js";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
function safetyNumber(myPubkey: string, peerPubkey: string): string {
|
||||
const a = Buffer.from(myPubkey, "hex");
|
||||
const b = Buffer.from(peerPubkey, "hex");
|
||||
const [lo, hi] = Buffer.compare(a, b) < 0 ? [a, b] : [b, a];
|
||||
const hash = createHash("sha256").update(lo).update(hi).digest();
|
||||
// Take first 15 bytes, split into 6 groups of 20 bits → 5 decimal digits each.
|
||||
const bits: number[] = [];
|
||||
for (let i = 0; i < 15; i++) {
|
||||
for (let b = 7; b >= 0; b--) {
|
||||
bits.push((hash[i]! >> b) & 1);
|
||||
}
|
||||
}
|
||||
const groups: string[] = [];
|
||||
for (let g = 0; g < 6; g++) {
|
||||
let val = 0;
|
||||
for (let i = 0; i < 20; i++) val = val * 2 + bits[g * 20 + i]!;
|
||||
groups.push(String(val % 100000).padStart(5, "0"));
|
||||
}
|
||||
return groups.join(" ");
|
||||
}
|
||||
|
||||
export async function runVerify(
|
||||
target: string | undefined,
|
||||
opts: { mesh?: string; json?: boolean } = {},
|
||||
): Promise<number> {
|
||||
const useColor = !process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string) => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
const dim = (s: string) => (useColor ? `\x1b[2m${s}\x1b[22m` : s);
|
||||
const clay = (s: string) => (useColor ? `\x1b[38;2;217;119;87m${s}\x1b[39m` : s);
|
||||
|
||||
const config = readConfig();
|
||||
const meshSlug = opts.mesh ?? config.meshes[0]?.slug;
|
||||
if (!meshSlug) {
|
||||
console.error(" No meshes joined. Run `claudemesh join <url>` first.");
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
const mesh = config.meshes.find((m) => m.slug === meshSlug);
|
||||
if (!mesh) {
|
||||
console.error(` Mesh "${meshSlug}" not found locally.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
return await withMesh({ meshSlug }, async (client) => {
|
||||
const peers = await client.listPeers();
|
||||
const targets = target
|
||||
? peers.filter((p) => p.displayName === target || p.pubkey === target || p.pubkey.startsWith(target))
|
||||
: peers;
|
||||
if (targets.length === 0) {
|
||||
console.error(` No peer matching "${target ?? "(all)"}" on mesh ${meshSlug}.`);
|
||||
return EXIT.NOT_FOUND;
|
||||
}
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify(targets.map((p) => ({
|
||||
mesh: meshSlug,
|
||||
peer: p.displayName,
|
||||
pubkey: p.pubkey,
|
||||
safetyNumber: safetyNumber(mesh.pubkey, p.pubkey),
|
||||
})), null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log(` ${dim("— safety numbers on")} ${bold(meshSlug)}`);
|
||||
console.log("");
|
||||
for (const p of targets) {
|
||||
const sn = safetyNumber(mesh.pubkey, p.pubkey);
|
||||
console.log(` ${bold(p.displayName)}`);
|
||||
console.log(` ${clay(sn)}`);
|
||||
console.log(` ${dim(`pubkey ${p.pubkey.slice(0, 16)}…`)}`);
|
||||
console.log("");
|
||||
}
|
||||
console.log(dim(" Compare these digits with your peer (phone, in person, not chat)."));
|
||||
console.log(dim(" If they match on both sides, the channel is not being intercepted."));
|
||||
console.log("");
|
||||
return EXIT.SUCCESS;
|
||||
});
|
||||
}
|
||||
@@ -1,111 +1,72 @@
|
||||
/**
|
||||
* Stateful welcome screen — shown when the user runs `claudemesh`
|
||||
* with no arguments. Detects install state + joined meshes + prints
|
||||
* the next action they should take.
|
||||
* `claudemesh` with no args + no joined meshes → unified onboarding.
|
||||
*
|
||||
* States, in priority order:
|
||||
* 1. MCP not registered in ~/.claude.json → run install
|
||||
* 2. Config dir exists but no meshes joined → run join
|
||||
* 3. Meshes joined, all reachable → run launch
|
||||
* 4. Meshes joined, broker unreachable → run status / doctor
|
||||
* One flow, one keystroke per decision. Collapses the old three-branch
|
||||
* picker (signup / login / join) into a linear path:
|
||||
*
|
||||
* 1. Already have an invite URL? → paste it, run the bare-URL join+launch.
|
||||
* (no account needed — invites are self-signed capabilities)
|
||||
* 2. Else: open the browser for sign-in + mesh creation at claudemesh.com
|
||||
* and fall back to paste-sync when the browser hand-off lands.
|
||||
*
|
||||
* The branch that used to be "register" collapses into the browser flow
|
||||
* (the web handles signup + mesh creation as one wizard there).
|
||||
*/
|
||||
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { homedir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { loadConfig } from "../state/config";
|
||||
import { VERSION } from "../version";
|
||||
import { createInterface } from "node:readline";
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
import { renderWelcome } from "~/ui/welcome/index.js";
|
||||
import { login } from "./login.js";
|
||||
import { render } from "~/ui/render.js";
|
||||
import { isInviteUrl, normaliseInviteUrl } from "~/utils/url.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
type State = "no-install" | "no-meshes" | "ready" | "broken-config";
|
||||
|
||||
function detectState(): State {
|
||||
// 1. MCP registered?
|
||||
const claudeConfig = join(homedir(), ".claude.json");
|
||||
let mcpRegistered = false;
|
||||
if (existsSync(claudeConfig)) {
|
||||
try {
|
||||
const cfg = JSON.parse(readFileSync(claudeConfig, "utf-8")) as {
|
||||
mcpServers?: Record<string, unknown>;
|
||||
};
|
||||
mcpRegistered = Boolean(cfg.mcpServers?.["claudemesh"]);
|
||||
} catch {
|
||||
/* treat parse errors as not-registered */
|
||||
}
|
||||
}
|
||||
if (!mcpRegistered) return "no-install";
|
||||
|
||||
// 2. Config parseable + has meshes?
|
||||
try {
|
||||
const cfg = loadConfig();
|
||||
return cfg.meshes.length === 0 ? "no-meshes" : "ready";
|
||||
} catch {
|
||||
return "broken-config";
|
||||
}
|
||||
function prompt(q: string): Promise<string> {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(q, (a) => { rl.close(); resolve(a.trim()); });
|
||||
});
|
||||
}
|
||||
|
||||
export function runWelcome(): void {
|
||||
const useColor =
|
||||
!process.env.NO_COLOR && process.env.TERM !== "dumb" && process.stdout.isTTY;
|
||||
const bold = (s: string): string => (useColor ? `\x1b[1m${s}\x1b[22m` : s);
|
||||
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 yellow = (s: string): string => (useColor ? `\x1b[33m${s}\x1b[39m` : s);
|
||||
export async function runWelcome(): Promise<number> {
|
||||
const config = readConfig();
|
||||
if (config.meshes.length > 0) return EXIT.SUCCESS;
|
||||
|
||||
console.log(bold(`claudemesh v${VERSION}`) + dim(" — peer mesh for Claude Code"));
|
||||
console.log("─".repeat(60));
|
||||
renderWelcome();
|
||||
|
||||
const state = detectState();
|
||||
render.info("Do you already have an invite link? (y/n) [n]");
|
||||
const hasInvite = (await prompt(" > ")).toLowerCase().startsWith("y");
|
||||
|
||||
switch (state) {
|
||||
case "no-install":
|
||||
console.log("Welcome. Let's get you set up.");
|
||||
console.log("");
|
||||
console.log(bold("Step 1:") + " register the MCP server + status hooks");
|
||||
console.log(` ${green("$")} claudemesh install`);
|
||||
console.log("");
|
||||
console.log(dim("Step 2 (after restart): claudemesh join <invite-url>"));
|
||||
console.log(dim("Step 3: claudemesh launch"));
|
||||
break;
|
||||
|
||||
case "no-meshes":
|
||||
console.log(green("✓") + " MCP registered. Now join a mesh.");
|
||||
console.log("");
|
||||
console.log(bold("Step 2:") + " join a mesh");
|
||||
console.log(` ${green("$")} claudemesh join https://claudemesh.com/join/<token>`);
|
||||
console.log("");
|
||||
console.log(
|
||||
dim(" Don't have an invite? Create one at ") +
|
||||
bold("https://claudemesh.com") +
|
||||
dim(" or ask a mesh owner."),
|
||||
);
|
||||
console.log("");
|
||||
console.log(dim("Step 3 (after joining): claudemesh launch"));
|
||||
break;
|
||||
|
||||
case "ready": {
|
||||
const cfg = loadConfig();
|
||||
const meshNames = cfg.meshes.map((m) => m.slug).join(", ");
|
||||
console.log(green("✓") + " MCP registered.");
|
||||
console.log(green("✓") + ` ${cfg.meshes.length} mesh(es) joined: ${meshNames}`);
|
||||
console.log("");
|
||||
console.log(bold("You're ready.") + " Launch Claude Code with real-time peer messages:");
|
||||
console.log(` ${green("$")} claudemesh launch`);
|
||||
console.log("");
|
||||
console.log(dim(" (Plain `claude` works too — messages pull-only via check_messages.)"));
|
||||
console.log("");
|
||||
console.log(dim("Health check: claudemesh status"));
|
||||
console.log(dim("Diagnostics: claudemesh doctor"));
|
||||
console.log(dim("All commands: claudemesh --help"));
|
||||
break;
|
||||
if (hasInvite) {
|
||||
render.blank();
|
||||
render.info("Paste your invite link (claudemesh.com/i/... or claudemesh://...)");
|
||||
const raw = await prompt(" > ");
|
||||
if (!raw || !isInviteUrl(raw)) {
|
||||
render.err("That doesn't look like a claudemesh invite URL.");
|
||||
render.hint("Check your email — the link starts with https://claudemesh.com/i/");
|
||||
return EXIT.INVALID_ARGS;
|
||||
}
|
||||
|
||||
case "broken-config":
|
||||
console.log(yellow("⚠") + " Your ~/.claudemesh/config.json is unreadable.");
|
||||
console.log("");
|
||||
console.log("Run diagnostics to see what's wrong:");
|
||||
console.log(` ${green("$")} claudemesh doctor`);
|
||||
break;
|
||||
const normalised = normaliseInviteUrl(raw);
|
||||
render.blank();
|
||||
render.ok(`Joining via ${normalised}`);
|
||||
const { runLaunch } = await import("./launch.js");
|
||||
await runLaunch(
|
||||
{
|
||||
join: normalised,
|
||||
name: process.env.USER ?? process.env.USERNAME ?? undefined,
|
||||
yes: false,
|
||||
},
|
||||
[],
|
||||
);
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
console.log("");
|
||||
// No invite → browser-first sign-in + mesh creation.
|
||||
render.blank();
|
||||
render.info("Opening claudemesh.com so you can sign in and create your first mesh.");
|
||||
render.hint("After sign-in, paste the sync token back here when prompted.");
|
||||
render.blank();
|
||||
return await login();
|
||||
}
|
||||
|
||||
export { runWelcome as _stub };
|
||||
|
||||
26
apps/cli/src/commands/whoami.ts
Normal file
26
apps/cli/src/commands/whoami.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { whoAmI } from "~/services/auth/facade.js";
|
||||
import { dim, icons } from "~/ui/styles.js";
|
||||
import { EXIT } from "~/constants/exit-codes.js";
|
||||
|
||||
export async function whoami(opts: { json?: boolean }): Promise<number> {
|
||||
const result = await whoAmI();
|
||||
|
||||
if (opts.json) {
|
||||
console.log(JSON.stringify({ schema_version: "1.0", ...result }, null, 2));
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
|
||||
if (!result.signed_in) {
|
||||
console.log(` Not signed in. Run \`claudemesh login\` to sign in.`);
|
||||
return EXIT.AUTH_FAILED;
|
||||
}
|
||||
|
||||
console.log(`\n Signed in as ${result.user!.display_name} (${result.user!.email})`);
|
||||
console.log(` Token source: ${result.token_source} ${dim("(~/.claudemesh/auth.json)")}`);
|
||||
if (result.meshes) {
|
||||
console.log(` Meshes: ${result.meshes.owned} owned, ${result.meshes.guest} guest`);
|
||||
}
|
||||
console.log();
|
||||
|
||||
return EXIT.SUCCESS;
|
||||
}
|
||||
Reference in New Issue
Block a user