Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/connect-command.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"eve": patch
---

Add a searchable `/connect` flow to the local dev TUI. It scaffolds an MCP connection, resolves its Vercel Connect connector, and reuses the model setup flow to create or link a Vercel project when needed. Local Vercel users now authorize Connect with their Vercel user ID instead of a reserved OIDC issuer.
8 changes: 8 additions & 0 deletions apps/fixtures/weather-agent/agent/connections/notion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { connect } from "@vercel/connect/eve";
import { defineMcpClientConnection } from "eve/connections";

export default defineMcpClientConnection({
url: "https://mcp.notion.com/mcp",
description: "Notion workspace: search and edit pages and databases.",
auth: connect("mcp.notion.com/notion"),
});
1 change: 1 addition & 0 deletions apps/fixtures/weather-agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"@vercel/connect": "0.2.2",
"eve": "workspace:*",
"zod": "catalog:"
}
Expand Down
13 changes: 10 additions & 3 deletions docs/guides/dev-tui.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,12 +30,13 @@ Errors render compactly with docs links highlighted. A code bug escaping your ag

## Slash commands

Each command echoes as an invocation line, asks through a bordered panel that takes the input area's place (one question at a time, separate from the chat transcript), and finishes with a one-line `⎿` result. Loading states stay on the ephemeral status line instead of piling into the transcript; model, channel, and the Vercel CLI commands (`/vc:install`, `/vc:login`) use the same green square pulse as the build phase, while `/deploy` keeps a spinner. Setup menus render the selected option with a filled arrow and an inverse label padded by one space on each side. Text prompts use a blinking block cursor over the character at the caret. The selected label is blue normally and yellow for warning rows.
Each command echoes as an invocation line, asks through a bordered panel that takes the input area's place (one question at a time, separate from the chat transcript), and finishes with a one-line `⎿` result. Loading states stay on the ephemeral status line instead of piling into the transcript; model, channel, connection, and the Vercel CLI commands (`/vc:install`, `/vc:login`) use the same green square pulse as the build phase, while `/deploy` keeps a spinner. A connection setup waiting for browser action changes that pulse and the word "browser" to yellow. Setup menus render the selected option with a filled arrow and an inverse label padded by one space on each side. Text prompts use a blinking block cursor over the character at the caret. The selected label is blue normally and yellow for warning rows.

| Command | Does |
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `/model` | Opens a configure menu that loops until Done (or Esc). See [Configure the model and provider](#configure-the-model-and-provider). |
| `/channels` | Shows the agent's channel list and adds the one you pick. See [Add a channel](#add-a-channel). |
| `/connect` | Shows the Vercel Connect MCP catalog and configures the server you pick. See [Add a connection](#add-a-connection). |
| `/deploy` | Ships the agent to Vercel production, linking the directory first when it is unlinked. |
| `/vc:install` | Installs the Vercel CLI. Available locally and on a remote session. |
| `/vc:login` | Logs in to Vercel locally. On a remote session, resolves the deployment's project, refreshes its OIDC token, and confirms any required Trusted Sources rule. |
Expand All @@ -44,7 +45,7 @@ Each command echoes as an invocation line, asks through a bordered panel that ta
| `/exit` | Quits the TUI. |
| `/help` | Lists the commands available for the current local or remote session. |

`/model`, `/channels`, and `/deploy` manage the project and are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`.
`/model`, `/channels`, `/connect`, and `/deploy` manage the local agent or its linked project. They are available only when `eve dev` runs the server locally, not when connected to a remote server with `--url`.

### Configure the model and provider

Expand All @@ -58,6 +59,12 @@ The provider row demands attention (a bold yellow "Configure model access" with

`/channels` shows the agent's channel list. Already-registered channels render as checked, focusable rows with an "Already installed" hint. Picking one adds it (including the Slack Connect provisioning), then installs the dependencies the scaffold added so the dev server can load the new channels right away. After each addition the list repaints with the channel checked, until Done (or Esc) leaves the flow.

### Add a connection

`/connect` shows a searchable list of MCP servers available through Vercel Connect. Already-authored connections remain checked. Logged-out users are directed to `/vc:login`. When the directory is not linked, selecting a server opens the same team and project flow used by `/model`, including creating a project or linking an existing one.

For a selected server, eve first tries to attach the provider's canonical connector. If that fails, choose an existing connector from a searchable list or create one with a specific name. The fallback stays scoped to the team selected by the linked project. A connector created by the current attempt is removed if attachment or connection-file patching fails. Successful setup writes `agent/connections/<name>.ts`, records the attached connector UID, installs the new dependency so the dev server can load it, then returns to the main prompt.

## Keyboard shortcuts

Chat and freeform `ask_question` inputs behave like a shell line editor.
Expand All @@ -83,7 +90,7 @@ When the agent needs something from you, the TUI asks inline.

- Tool approvals are a `y` or `n`.
- Option questions let you pick with `↑` / `↓` and `Enter`, or you can compose a multi-line freeform answer.
- If a tool needs an authorized [connection](../connections), the URL shows up right in the transcript, and the turn picks back up once you finish the flow.
- If a tool needs an authorized [connection](../connections), the URL shows up right in the transcript, and the turn picks back up once you finish the flow. The same local `eve dev` server owns the callback route, so keep that command running until the browser returns. `eve dev --url` connects to an already-running server and does not start a local callback host.

## Control what logs show

Expand Down
4 changes: 4 additions & 0 deletions packages/eve-catalog/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,8 @@ describe("integration catalog", () => {
]);
expect(connectionProtocols(getIntegrationEntry("linear")!.connection!)).toEqual(["mcp"]);
});

it("uses Linear's current MCP endpoint", () => {
expect(getIntegrationEntry("linear")?.connection?.mcp?.url).toBe("https://mcp.linear.app/mcp");
});
});
2 changes: 1 addition & 1 deletion packages/eve-catalog/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ export const INTEGRATIONS: readonly IntegrationEntry[] = [
surfaces: { scaffoldable: true, gallery: true },
connection: {
description: "Linear workspace: issues, projects, cycles, and comments.",
mcp: { url: "https://mcp.linear.app/sse" },
mcp: { url: "https://mcp.linear.app/mcp" },
},
},
{
Expand Down
20 changes: 20 additions & 0 deletions packages/eve/src/cli/dev/tui/agent-header.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { describe, expect, it } from "vitest";
import type { AgentInfoResult, AgentInfoToolEntry } from "#client/index.js";

import { AGENT_HEADER_TIPS, buildAgentHeader, pickAgentHeaderTip } from "./agent-header.js";
import { stripAnsi } from "./terminal-text.js";
import { createTheme } from "./theme.js";

const FRAMEWORK_TOOL: AgentInfoToolEntry = {
Expand Down Expand Up @@ -133,6 +134,25 @@ describe("buildAgentHeader", () => {
expect(remote.join("\n")).not.toContain("/channels");
});

it("renders the /connect tip with a blue command", () => {
const colorTheme = createTheme({ color: true, unicode: false });
const tip = AGENT_HEADER_TIPS.find((candidate) => candidate.includes("/connect"));

expect(tip).toBe("Tip: /connect to seamlessly add MCP Connections to your agent");
if (tip === undefined) return;

const line = buildAgentHeader({
name: "weather-agent",
info: INFO,
theme: colorTheme,
width: 120,
tip,
}).at(-1);

expect(stripAnsi(line ?? "")).toBe(` ${tip}`);
expect(line).toContain(colorTheme.colors.blue("/connect"));
});

it("keeps the discovery-diagnostics line when the compiler reported problems", () => {
const info: AgentInfoResult = {
...INFO,
Expand Down
13 changes: 12 additions & 1 deletion packages/eve/src/cli/dev/tui/agent-header.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
*/

import type { AgentInfoResult } from "#client/index.js";
import { isPromptControlCommand } from "./prompt-commands.js";
import type { Theme } from "./theme.js";
import { truncate } from "./tool-format.js";

Expand All @@ -30,6 +31,7 @@ export const AGENT_HEADER_TIPS: readonly string[] = [
"Use /channels to add more ways to reach your agent.",
"Use /deploy to see your agent go live.",
"Type /help to see every command.",
"Tip: /connect to seamlessly add MCP Connections to your agent",
];

/** Picks one tip; `random` is a test seam over Math.random. */
Expand Down Expand Up @@ -75,12 +77,21 @@ export function buildAgentHeader(input: AgentHeaderInput): string[] {
}

if (input.tip !== undefined) {
lines.push(` ${c.dim(truncate(input.tip, Math.max(8, width - 2)))}`);
lines.push(` ${renderTip(input.tip, Math.max(8, width - 2), theme)}`);
}

return lines;
}

function renderTip(tip: string, width: number, theme: Theme): string {
return truncate(tip, width)
.split(/(\/[a-z:-]+)/u)
.map((part) =>
isPromptControlCommand(part) ? theme.colors.blue(part) : theme.colors.dim(part),
)
.join("");
}

function plural(count: number): string {
return count === 1 ? "" : "s";
}
7 changes: 7 additions & 0 deletions packages/eve/src/cli/dev/tui/prompt-commands.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,11 @@ describe("parsePromptCommand", () => {
name: "channels",
argument: "",
});
expect(parsePromptCommand("/connect")).toEqual({
type: "extension",
name: "connect",
argument: "",
});
expect(parsePromptCommand("/deploy")).toEqual({
type: "extension",
name: "deploy",
Expand Down Expand Up @@ -94,6 +99,7 @@ describe("promptCommandsFor", () => {
const names = promptCommandsFor("local").map((command) => command.name);
expect(names).toContain("model");
expect(names).toContain("channels");
expect(names).toContain("connect");
expect(names).toContain("deploy");
expect(names).toContain("vc:install");
expect(names).toContain("vc:login");
Expand All @@ -107,6 +113,7 @@ describe("promptCommandsFor", () => {
expect(names).not.toContain("vc:auth");
expect(names).not.toContain("model");
expect(names).not.toContain("channels");
expect(names).not.toContain("connect");
expect(names).not.toContain("deploy");
});

Expand Down
9 changes: 9 additions & 0 deletions packages/eve/src/cli/dev/tui/prompt-commands.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export type PromptCommandExtensionName =
| "model"
| "channels"
| "connect"
| "deploy"
| "vc:install"
| "vc:login";
Expand Down Expand Up @@ -104,6 +105,14 @@ const PROMPT_COMMAND_DEFINITIONS = [
build: () => ({ type: "extension", name: "channels", argument: "" }),
targets: ["local"],
},
{
name: "connect",
aliases: [],
description: "Add an MCP server through Vercel Connect",
takesArgument: false,
build: () => ({ type: "extension", name: "connect", argument: "" }),
targets: ["local"],
},
{
name: "deploy",
aliases: [],
Expand Down
91 changes: 91 additions & 0 deletions packages/eve/src/cli/dev/tui/runner.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,57 @@ describe("EveTUIRunner native continuation state", () => {
});
});

describe("EveTUIRunner connection authorization", () => {
it("continues a parked interactive authorization session until the callback completes", async () => {
const prompts: Array<string | undefined> = ["connect linear", undefined];
const updates: Array<{ name: string; state: string }> = [];
const pendingCounts: number[] = [];
const session = sessionYielding([
{
type: "authorization.required",
data: {
authorization: { url: "https://connect.vercel.com/authorize/linear" },
description: "Authorization required for linear",
name: "linear",
webhookUrl: "https://eve.test/connections/linear/callback",
},
},
{ type: "session.waiting", data: { wait: "connection-authorization" } },
]);
vi.spyOn(session, "stream").mockImplementation(async function* () {
yield {
type: "authorization.completed",
data: { name: "linear", outcome: "authorized" },
} as HandleMessageStreamEvent;
yield {
type: "session.waiting",
data: { wait: "next-user-message" },
} as HandleMessageStreamEvent;
});
const renderer: AgentTUIRenderer = {
readPrompt: vi.fn(async () => prompts.shift()),
upsertConnectionAuth: (update) => updates.push({ name: update.name, state: update.state }),
setConnectionAuthPendingCount: (count) => pendingCounts.push(count),
renderStream: vi.fn(async (result) => {
for await (const event of result.events as AsyncIterable<AgentTUIStreamEvent>) {
void event;
}
}),
};

const runner = new EveTUIRunner({ session, renderer, name: "Weather Agent" });
await runner.run();

expect(session.stream).toHaveBeenCalledTimes(1);
expect(updates).toEqual([
{ name: "linear", state: "required" },
{ name: "linear", state: "pending" },
{ name: "linear", state: "authorized" },
]);
expect(pendingCounts).toEqual([1, 0]);
});
});

describe("EveTUIRunner failure rendering", () => {
it("renders one error block for a step/turn/session failure cascade", async () => {
const prompts: Array<string | undefined> = ["hello", undefined];
Expand Down Expand Up @@ -1629,6 +1680,46 @@ describe("EveTUIRunner Vercel status line", () => {
expect(info).toHaveBeenCalledTimes(2);
});

it("forces a runtime rebuild after /connect adds a connection", async () => {
const client = stubClient();
vi.spyOn(client, "info").mockResolvedValue(AGENT_INFO);
const requests: URL[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: Parameters<typeof fetch>[0]) => {
const url = new URL(
typeof input === "string" ? input : input instanceof URL ? input : input.url,
);
requests.push(url);
return Response.json({ revision: url.searchParams.get("force") === "1" ? "next" : "base" });
}),
);
const prompts: Array<string | undefined> = ["/connect", undefined];
const renderer = fakeRenderer({ readPrompt: vi.fn(async () => prompts.shift()) });
const connectOutcome = {
message: "Connections added: linear.",
effect: { kind: "connection-added" },
} satisfies PromptCommandOutcome;

const runner = new EveTUIRunner({
session: stubSession(),
client,
renderer,
serverUrl: "http://localhost:3000",
name: "Weather Agent",
promptCommandHandler: { handle: async () => connectOutcome },
});
await runner.run();

expect(
requests.some(
(url) =>
url.pathname === "/eve/v1/dev/runtime-artifacts/rebuild" &&
url.searchParams.get("force") === "1",
),
).toBe(true);
});

it("never pushes Vercel status for a remote --url session", async () => {
const setVercelStatus = vi.fn();
const renderer = fakeRenderer({ setVercelStatus });
Expand Down
Loading
Loading