Skip to content
Closed
13 changes: 13 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,7 @@ const commandLoaders = {
assertStudioWorkspaceBuilt().then(() => import("./commands/preview.js").then((m) => m.default)),
publish: () => import("./commands/publish.js").then((m) => m.default),
open: () => import("./commands/open.js").then((m) => m.default),
"app-tools": () => import("./commands/app-tools.js").then((m) => m.default),
render: () => import("./commands/render.js").then((m) => m.default),
lint: () => import("./commands/lint.js").then((m) => m.default),
check: () => import("./commands/check.js").then((m) => m.default),
Expand Down Expand Up @@ -242,6 +243,18 @@ if (
});
}

// A resumed Claude Code session has a new inbox: a command run in a linked project points the desktop app at it.
if (!isHelp && command !== "unknown") {
void import("./utils/agentLink.js")
.then(({ refreshAgentLinks }) =>
refreshAgentLinks([
process.cwd(),
...process.argv.slice(3).filter((a) => !a.startsWith("-")),
]),
)
.catch(() => {});
}

// `events` skips the update check too — a skill-usage beacon must not add
// network latency or trigger a background self-upgrade on the calling skill.
// `telemetry` skips it because update metadata must never race the command
Expand Down
84 changes: 84 additions & 0 deletions packages/cli/src/commands/app-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { defineCommand } from "citty";
import type { Example } from "./_examples.js";
import { setCommandExitCode } from "../utils/commandResult.js";
import { resolveProject } from "../utils/project.js";
import {
appToolsRequest,
imagesToFiles,
initializeParams,
readAppTools,
type AppToolsEndpoint,
} from "../utils/appTools.js";

export const examples: Example[] = [
["What the desktop app offers this turn", "hyperframes app-tools list"],
[
"Remove a picture's background",
`hyperframes app-tools call image.removeBackground '{"asset":"assets/photo.png"}'`,
],
];

const NOT_LINKED =
"No desktop app tools for this project: they are there only while the HyperFrames desktop app's chat sends a request to this session.";

function endpointFor(dir: string | undefined): AppToolsEndpoint | null {
const project = resolveProject(dir);
const endpoint = project && readAppTools(project.dir);
if (!endpoint) {
console.error(NOT_LINKED);
setCommandExitCode(1);
}
return endpoint;
}

async function answer(work: () => Promise<unknown>): Promise<void> {
try {
console.log(JSON.stringify(await work(), null, 2));
} catch (error) {
console.error(error instanceof Error ? error.message : String(error));
setCommandExitCode(1);
}
}

const dir = { type: "string", description: "Project directory (default: current)" } as const;

const list = defineCommand({
meta: { name: "list", description: "The app's instructions and the tools it offers now" },
args: { dir },
async run({ args }) {
const endpoint = endpointFor(args.dir);
if (!endpoint) return;
await answer(async () => {
const { instructions } = await appToolsRequest(endpoint, "initialize", initializeParams);
const { tools } = await appToolsRequest(endpoint, "tools/list");
return { instructions, tools };
});
},
});

const call = defineCommand({
meta: { name: "call", description: "Call one of the app's tools with JSON arguments" },
args: {
tool: { type: "positional", description: "Tool name, e.g. image.cutOut", required: true },
input: { type: "positional", description: "JSON arguments (default: {})", required: false },
dir,
},
async run({ args }) {
const endpoint = endpointFor(args.dir);
if (!endpoint) return;
await answer(async () => {
const input: unknown = JSON.parse(args.input || "{}");
return imagesToFiles(
await appToolsRequest(endpoint, "tools/call", { name: args.tool, arguments: input }),
);
});
},
});

export default defineCommand({
meta: {
name: "app-tools",
description: "Use the HyperFrames desktop app's picture and window tools from a linked session",
},
subCommands: { list, call },
});
10 changes: 9 additions & 1 deletion packages/cli/src/commands/open.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ const WHY_NOT: Record<Extract<DesktopOpenResult, { opened: false }>["reason"], s
function printResult(project: ProjectDir, result: DesktopOpenResult): void {
if (result.opened) {
console.log(`${c.success("◇")} Opening ${c.accent(project.name)} in ${result.app}`);
if (result.handedOver) {
if (result.link) {
console.log(
` ${c.dim("Its chat goes to this Claude Code session while it is open, then picks up the conversation.")}`,
);
if (!result.link.relay)
console.log(
` ${c.dim(`To reach it also when permission prompts are skipped, keep ${c.accent("npx hyperframes preview --foreground")} running as this session's own background task.`)}`,
);
} else if (result.handedOver) {
const agent = result.handedOver.engine === "claude" ? "Claude Code" : "Codex";
console.log(` ${c.dim(`Its chat picks up this ${agent} conversation.`)}`);
}
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/commands/preview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import { createRequire } from "node:module";
import * as clack from "@clack/prompts";
import { c } from "../ui/colors.js";
import { desktopHint } from "../utils/desktopApp.js";
import { newRelayToken, writeAgentLink } from "../utils/agentLink.js";
import { boundToLoopback, relayFor } from "../server/desktopRoutes.js";
import { isDevMode } from "../utils/env.js";
import { normalizeErrorMessage as errorMessage } from "../utils/errorMessage.js";
import { buildNpxCommand } from "../utils/npxCommand.js";
Expand Down Expand Up @@ -1578,6 +1580,7 @@ async function runEmbeddedMode(
// Compute everything that may throw before acquiring the fs.watch handle.
// Once createStudioServer returns, every subsequent exit path must close it.
const serverBuildSignature = await loadPreviewServerBuildSignature();
const relayToken = boundToLoopback() ? newRelayToken() : undefined;
const {
app,
watcher,
Expand All @@ -1587,6 +1590,7 @@ async function runEmbeddedMode(
projectName: pName,
autoProxy: options?.autoProxy,
browserGpuMode: options?.browserGpuMode,
relayToken,
});
let result: FindPortResult;
try {
Expand Down Expand Up @@ -1642,6 +1646,8 @@ async function runEmbeddedMode(
}

const url = `http://localhost:${result.port}`;
const relay = relayFor(`localhost:${result.port}`, relayToken);
if (relay) writeAgentLink(dir, { relay });
if (options?.json) {
writeLifecycleJson(foregroundPreviewReadyPayload(pName, url, dir, process.pid));
} else {
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/help.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ const GROUPS: Group[] = [
["publish", "Upload a project to a stable URL (private by default)"],
["render", "Render a composition to MP4 or WebM"],
["open", "Open a project in the HyperFrames desktop app"],
["app-tools", "Use the desktop app's picture and window tools from a linked session"],
],
},
{
Expand Down
97 changes: 92 additions & 5 deletions packages/cli/src/server/desktopRoutes.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { Hono } from "hono";
import { describe, expect, it, vi } from "vitest";
import { desktopDownloadUrl, type DesktopOpenResult } from "../utils/desktopApp.js";
import { mountDesktopRoutes, sameOriginPost } from "./desktopRoutes.js";
import { mountDesktopRoutes, relayFor, sameOriginPost } from "./desktopRoutes.js";

const STUDIO = {
host: "localhost:3002",
Expand All @@ -12,15 +12,22 @@ const OPENED: DesktopOpenResult = {
opened: true,
app: "the HyperFrames desktop app",
handedOver: null,
link: null,
};

function server(
ready: boolean,
place: { platform?: NodeJS.Platform; installed?: () => boolean } = {},
place: {
platform?: NodeJS.Platform;
installed?: () => boolean;
relayToken?: string;
env?: NodeJS.ProcessEnv;
post?: (inbox: string, text: string, token?: string) => Promise<void>;
} = {},
) {
const app = new Hono();
const open = vi.fn((_dir: string): DesktopOpenResult => OPENED);
mountDesktopRoutes(app, "/films/a", { ready, open, ...place });
const open = vi.fn((_dir: string, _options?: unknown): DesktopOpenResult => OPENED);
mountDesktopRoutes(app, "/films/a", { ready, open, env: {}, ...place });
const post = (headers: Record<string, string>) =>
app.request("/api/open-in-desktop", { method: "POST", headers });
return { app, open, post };
Expand Down Expand Up @@ -67,7 +74,71 @@ describe("open-in-desktop routes", () => {
it("once live, opens this server's project for Studio's own POST", async () => {
const { open, post } = server(true);
expect(await (await post(STUDIO)).json()).toEqual(OPENED);
expect(open).toHaveBeenCalledWith("/films/a");
expect(open).toHaveBeenCalledWith("/films/a", { env: {} });
});

it("inside a Claude Code session, hands the app this preview as the session's relay", async () => {
const env = { CLAUDE_CODE_MESSAGING_SOCKET: "/tmp/cc-socks/1.sock" };
const { open, post } = server(true, { relayToken: "t0k", env });
await post(STUDIO);
expect(open).toHaveBeenCalledWith("/films/a", {
env,
relay: {
url: "http://localhost:3002/api/agent-link/message",
token: "t0k",
inbox: "/tmp/cc-socks/1.sock",
},
});
});
});

describe("agent relay", () => {
const ENV = {
CLAUDE_CODE_MESSAGING_SOCKET: "/tmp/cc-socks/1.sock",
CLAUDE_CODE_MESSAGING_TOKEN: "own",
};
const send = (
app: Hono,
{
auth = "Bearer t0k",
host = "127.0.0.1:3002",
body = { text: "make it blue" } as unknown,
} = {},
) =>
app.request("/api/agent-link/message", {
method: "POST",
headers: { host, authorization: auth, "content-type": "application/json" },
body: JSON.stringify(body),
});

it("forwards the app's message to the session that started this preview", async () => {
const post = vi.fn(async () => {});
const { app } = server(true, { relayToken: "t0k", env: ENV, post });
const res = await send(app);
expect(res.status).toBe(202);
expect(post).toHaveBeenCalledWith("/tmp/cc-socks/1.sock", "make it blue", "own");
});

it("refuses without the token, through a rebound host, or with no text", async () => {
const post = vi.fn(async () => {});
const { app } = server(true, { relayToken: "t0k", env: ENV, post });
expect((await send(app, { auth: "Bearer nope" })).status).toBe(403);
expect((await send(app, { host: "evil.example:3002" })).status).toBe(404);
expect((await send(app, { body: { text: "" } })).status).toBe(400);
expect(post).not.toHaveBeenCalled();
});

it("is not there outside a Claude Code session", async () => {
const { app } = server(true, { relayToken: "t0k", env: {} });
expect((await send(app)).status).toBe(404);
});

it("says when the session's inbox is gone", async () => {
const post = vi.fn(async () => {
throw new Error("ENOENT");
});
const { app } = server(true, { relayToken: "t0k", env: ENV, post });
expect((await send(app)).status).toBe(502);
});
});

Expand All @@ -79,3 +150,19 @@ describe("sameOriginPost", () => {
expect(sameOriginPost({})).toBe(false);
});
});

describe("relayFor", () => {
it("offers a relay only from a preview bound to this machine", () => {
const env = { CLAUDE_CODE_MESSAGING_SOCKET: "/tmp/cc-socks/1.sock" };
expect(relayFor("localhost:3002", "t", env)?.url).toBe(
"http://localhost:3002/api/agent-link/message",
);
expect(
relayFor("localhost:3002", "t", { ...env, HYPERFRAMES_PREVIEW_HOST: "127.0.0.1" }),
).toBeDefined();
expect(
relayFor("localhost:3002", "t", { ...env, HYPERFRAMES_PREVIEW_HOST: "0.0.0.0" }),
).toBeUndefined();
expect(relayFor("localhost:3002", "t", {})).toBeUndefined();
});
});
57 changes: 55 additions & 2 deletions packages/cli/src/server/desktopRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,15 @@ import {
desktopInstalled,
openInDesktop,
} from "../utils/desktopApp.js";
import { identityAllowed } from "./telemetryIdentity.js";
import {
AGENT_RELAY_PATH,
postToInbox,
relayAuthorized,
type AgentRelay,
} from "../utils/agentLink.js";
import { identityAllowed, isLoopbackHost } from "./telemetryIdentity.js";

const RELAY_MAX_CHARS = 200_000;

/** A bodiless POST is a simple request, so any page can send one to localhost: it has to come from this Studio. */
export function sameOriginPost(headers: {
Expand All @@ -19,6 +27,21 @@ export function sameOriginPost(headers: {
return origin === undefined || origin === `http://${host}`;
}

export const boundToLoopback = (env: NodeJS.ProcessEnv = process.env): boolean => {
const bound = env.HYPERFRAMES_PREVIEW_HOST?.trim();
return !bound || isLoopbackHost(bound);
};

export function relayFor(
host: string,
token: string | undefined,
env: NodeJS.ProcessEnv = process.env,
): AgentRelay | undefined {
const inbox = env.CLAUDE_CODE_MESSAGING_SOCKET;
if (!token || !inbox || !boundToLoopback(env)) return undefined;
return { url: `http://${host}${AGENT_RELAY_PATH}`, token, inbox };
}

/** Edit with Framey's route: whether to show it, where to download, and whether the app takes the project. */
export function mountDesktopRoutes(
app: Hono,
Expand All @@ -28,6 +51,17 @@ export function mountDesktopRoutes(
open = openInDesktop,
platform = process.platform,
installed = () => desktopInstalled({ platform }),
relayToken,
env = process.env,
post = postToInbox,
}: {
ready?: boolean;
open?: typeof openInDesktop;
platform?: NodeJS.Platform;
installed?: () => boolean;
relayToken?: string;
env?: NodeJS.ProcessEnv;
post?: typeof postToInbox;
} = {},
): void {
const downloadUrl = desktopDownloadUrl(platform);
Expand All @@ -47,6 +81,25 @@ export function mountDesktopRoutes(
reason: "handoff-unavailable",
downloadUrl,
});
return c.json(open(projectDir));
const relay = relayFor(c.req.header("host") ?? "", relayToken, env);
return c.json(open(projectDir, { env, ...(relay && { relay }) }));
});
app.post(AGENT_RELAY_PATH, async (c) => {
const inbox = env.CLAUDE_CODE_MESSAGING_SOCKET;
if (!relayToken || !inbox || !identityAllowed(c.req.header("host")))
return c.json({ error: "not-found" }, 404);
if (!relayAuthorized(c.req.header("authorization"), relayToken))
return c.json({ error: "forbidden" }, 403);
const text = relayText(await c.req.json().catch(() => null));
if (!text) return c.json({ error: "bad-request" }, 400);
return post(inbox, text, env.CLAUDE_CODE_MESSAGING_TOKEN).then(
() => c.json({ sent: true }, 202),
() => c.json({ error: "inbox-unreachable" }, 502),
);
});
}

function relayText(body: unknown): string | null {
const text = typeof body === "object" && body !== null && "text" in body ? body.text : null;
return typeof text === "string" && text && text.length <= RELAY_MAX_CHARS ? text : null;
}
3 changes: 2 additions & 1 deletion packages/cli/src/server/studioServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ export interface StudioServerOptions {
browserGpuMode?: BrowserGpuMode;
/** Where project histories are kept; defaults to ~/.cache/hyperframes/history. */
historyRoot?: string;
relayToken?: string;
}

export interface StudioServer {
Expand Down Expand Up @@ -974,7 +975,7 @@ export function createStudioServer(options: StudioServerOptions): StudioServer {
});
});

mountDesktopRoutes(app, projectDir);
mountDesktopRoutes(app, projectDir, { relayToken: options.relayToken });

// ── Pre-flight checks for render ────────────────────────────────────────
// Intercept render requests before they reach the shared API so we can
Expand Down
Loading
Loading