chore(cli-v2): un-ignore CLI source tree for binary release workflow
The CLI source (242 files, ~14k lines) was gitignored during the earlier cli→cli-v2 reorg so only the published npm package carried it. That blocks the GitHub Actions release workflow (release-cli.yml), which clones the repo fresh on each runner and needs the source to compile binaries via `bun build --compile`. Moves the gitignore from root-level to `apps/cli-v2/.gitignore` with only the usual build artefacts excluded (node_modules, dist, .turbo, .cache). Source is now in git at apps/cli-v2/src/. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
123
apps/cli-v2/src/commands/hook.ts
Normal file
123
apps/cli-v2/src/commands/hook.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* `claudemesh hook <status>` — Claude Code hook handler.
|
||||
*
|
||||
* Registered as a Stop + UserPromptSubmit hook by `claudemesh install`.
|
||||
* On each turn boundary, Claude Code invokes:
|
||||
*
|
||||
* Stop → `claudemesh hook idle`
|
||||
* UserPromptSubmit → `claudemesh hook working`
|
||||
*
|
||||
* We read the Claude Code hook JSON payload from stdin (contains cwd +
|
||||
* session_id), then POST `/hook/set-status` to EVERY joined mesh's
|
||||
* broker with {cwd, pid, status, session_id}. Each broker looks up
|
||||
* its local presence row by (pid, cwd) and updates status.
|
||||
*
|
||||
* Fire-and-forget, silent. Hooks must NEVER block Claude Code or
|
||||
* surface errors to the user. Debug logging available via
|
||||
* CLAUDEMESH_HOOK_DEBUG=1.
|
||||
*
|
||||
* Why send to every broker? A user joined to multiple meshes has
|
||||
* one presence row per mesh, each on its own broker. A turn boundary
|
||||
* updates the status on every broker where this session is active.
|
||||
* Brokers that don't have a matching presence just queue the signal
|
||||
* in pending_status (harmless, TTL-swept).
|
||||
*/
|
||||
|
||||
import { readConfig } from "~/services/config/facade.js";
|
||||
|
||||
const DEBUG = process.env.CLAUDEMESH_HOOK_DEBUG === "1";
|
||||
|
||||
function debug(msg: string): void {
|
||||
if (DEBUG) console.error(`[claudemesh-hook] ${msg}`);
|
||||
}
|
||||
|
||||
/** WS URL → HTTP URL (same host, swap scheme). */
|
||||
function wsToHttp(wsUrl: string): string {
|
||||
try {
|
||||
const u = new URL(wsUrl);
|
||||
const httpScheme = u.protocol === "wss:" ? "https:" : "http:";
|
||||
return `${httpScheme}//${u.host}`;
|
||||
} catch {
|
||||
return wsUrl;
|
||||
}
|
||||
}
|
||||
|
||||
async function readStdinJson(): Promise<Record<string, unknown>> {
|
||||
if (process.stdin.isTTY) return {};
|
||||
const chunks: Uint8Array[] = [];
|
||||
const reader = process.stdin;
|
||||
try {
|
||||
for await (const chunk of reader) {
|
||||
chunks.push(chunk as Uint8Array);
|
||||
if (chunks.reduce((n, c) => n + c.length, 0) > 256 * 1024) break;
|
||||
}
|
||||
const raw = Buffer.concat(chunks).toString("utf-8").trim();
|
||||
if (!raw) return {};
|
||||
return JSON.parse(raw) as Record<string, unknown>;
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
async function postHook(
|
||||
brokerWsUrl: string,
|
||||
body: Record<string, unknown>,
|
||||
): Promise<void> {
|
||||
const base = wsToHttp(brokerWsUrl);
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const t = setTimeout(() => controller.abort(), 1000);
|
||||
await fetch(`${base}/hook/set-status`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
signal: controller.signal,
|
||||
}).finally(() => clearTimeout(t));
|
||||
} catch (e) {
|
||||
debug(`post failed ${base}: ${e instanceof Error ? e.message : e}`);
|
||||
}
|
||||
}
|
||||
|
||||
export async function runHook(args: string[]): Promise<void> {
|
||||
const status = args[0];
|
||||
if (!status || !["idle", "working", "dnd"].includes(status)) {
|
||||
// Silent no-op — we never want a hook to surface an error.
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
// Read Claude Code's stdin payload for cwd + session_id.
|
||||
const stdinTimeout = new Promise<Record<string, unknown>>((r) =>
|
||||
setTimeout(() => r({}), 500),
|
||||
);
|
||||
const payload = await Promise.race([readStdinJson(), stdinTimeout]);
|
||||
const cwd =
|
||||
(typeof payload.cwd === "string" && payload.cwd) ||
|
||||
process.env.CLAUDE_PROJECT_DIR ||
|
||||
process.cwd();
|
||||
const sessionId =
|
||||
(typeof payload.session_id === "string" && payload.session_id) || "";
|
||||
|
||||
// Fan out to EVERY joined mesh's broker in parallel.
|
||||
let config;
|
||||
try {
|
||||
config = readConfig();
|
||||
} catch (e) {
|
||||
debug(`config load failed: ${e instanceof Error ? e.message : e}`);
|
||||
process.exit(0);
|
||||
}
|
||||
if (config.meshes.length === 0) {
|
||||
debug("no joined meshes, nothing to do");
|
||||
process.exit(0);
|
||||
}
|
||||
|
||||
const body = { cwd, pid: process.ppid, status, session_id: sessionId };
|
||||
debug(
|
||||
`status=${status} cwd=${cwd} meshes=${config.meshes.length} session=${sessionId.slice(0, 8)}`,
|
||||
);
|
||||
|
||||
// Dedupe by brokerUrl — if multiple meshes share a broker, one POST
|
||||
// covers them (broker resolves presence by cwd+pid regardless).
|
||||
const brokerUrls = [...new Set(config.meshes.map((m) => m.brokerUrl))];
|
||||
await Promise.all(brokerUrls.map((url) => postHook(url, body)));
|
||||
process.exit(0);
|
||||
}
|
||||
Reference in New Issue
Block a user