Skip to content
Merged
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
32 changes: 12 additions & 20 deletions extensions/cli/src/commands/ls.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import { render } from "ink";
import React from "react";

import { getAccessToken, loadAuthConfig } from "../auth/workos.js";
import { env } from "../env.js";
import { listSessions, loadSessionById } from "../session.js";
import { SessionSelector } from "../ui/SessionSelector.js";
import { ApiRequestError, post } from "../util/apiClient.js";
import { logger } from "../util/logger.js";

import { chat } from "./chat.js";
Expand All @@ -27,26 +26,19 @@ function setSessionId(sessionId: string): void {
}

export async function getTunnelForAgent(agentId: string): Promise<string> {
const authConfig = loadAuthConfig();
const accessToken = getAccessToken(authConfig);

const resp = await fetch(
new URL(`agents/${encodeURIComponent(agentId)}/tunnel`, env.apiBase),
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
},
);
if (!resp.ok) {
throw new Error(
`Failed to get tunnel for agent ${agentId}: ${await resp.text()}`,
try {
const response = await post<{ url: string }>(
`agents/${encodeURIComponent(agentId)}/tunnel`,
);
return response.data.url;
} catch (error) {
if (error instanceof ApiRequestError) {
throw new Error(
`Failed to get tunnel for agent ${agentId}: ${error.response || error.statusText}`,
);
}
throw error;
}
const data = await resp.json();
return data.url;
}

/**
Expand Down
21 changes: 21 additions & 0 deletions extensions/cli/src/commands/remote.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ vi.mock("../env.js");
vi.mock("../telemetry/telemetryService.js");
vi.mock("../ui/index.js");
vi.mock("../util/git.js");
vi.mock("../util/exit.js");

const mockWorkos = vi.mocked(await import("../auth/workos.js"));
const mockEnv = vi.mocked(await import("../env.js"));
const mockGit = vi.mocked(await import("../util/git.js"));
const mockStartRemoteTUIChat = vi.mocked(await import("../ui/index.js"));
const mockExit = vi.mocked(await import("../util/exit.js"));

// Mock fetch globally
const mockFetch = vi.fn();
Expand Down Expand Up @@ -62,6 +64,10 @@ describe("remote command", () => {

mockFetch.mockResolvedValue({
ok: true,
headers: {
get: (name: string) =>
name === "content-type" ? "application/json" : null,
},
json: async () => ({
id: "test-agent-id",
url: "ws://test-url.com",
Expand All @@ -70,6 +76,9 @@ describe("remote command", () => {
});

mockStartRemoteTUIChat.startRemoteTUIChat.mockResolvedValue({} as any);

// Mock gracefulExit to prevent process.exit during tests
mockExit.gracefulExit.mockResolvedValue(undefined);
});

it("should include idempotency key in request body when provided", async () => {
Expand Down Expand Up @@ -151,10 +160,18 @@ describe("remote command", () => {
mockFetch
.mockResolvedValueOnce({
ok: true,
headers: {
get: (name: string) =>
name === "content-type" ? "application/json" : null,
},
json: async () => ({ url: "ws://tunnel-url.com", port: 9090 }),
})
.mockResolvedValue({
ok: true,
headers: {
get: (name: string) =>
name === "content-type" ? "application/json" : null,
},
json: async () => ({
id: "test-agent-id",
url: "ws://test-url.com",
Expand Down Expand Up @@ -187,6 +204,10 @@ describe("remote command", () => {

mockFetch.mockResolvedValueOnce({
ok: true,
headers: {
get: (name: string) =>
name === "content-type" ? "application/json" : null,
},
json: async () => tunnelResponse,
});

Expand Down
100 changes: 30 additions & 70 deletions extensions/cli/src/commands/remote.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import chalk from "chalk";

import { getAccessToken, loadAuthConfig } from "../auth/workos.js";
import { env } from "../env.js";
import { telemetryService } from "../telemetry/telemetryService.js";
import { startRemoteTUIChat } from "../ui/index.js";
import {
ApiRequestError,
AuthenticationRequiredError,
post,
} from "../util/apiClient.js";
import { gracefulExit } from "../util/exit.js";
import { getRepoUrl } from "../util/git.js";
import { logger } from "../util/logger.js";
Expand All @@ -28,12 +32,6 @@ type AgentCreationResponse = TunnelResponse & {
id: string;
};

class AuthenticationRequiredError extends Error {
constructor() {
super("Not authenticated. Please run 'cn login' first.");
}
}

export async function remote(
prompt: string | undefined,
options: RemoteCommandOptions = {},
Expand All @@ -46,19 +44,12 @@ export async function remote(
return;
}

const accessToken = requireAccessToken();

if (options.id) {
await connectExistingAgent(
options.id,
accessToken,
actualPrompt,
options.start,
);
await connectExistingAgent(options.id, actualPrompt, options.start);
return;
}

await createAndConnectRemoteEnvironment(accessToken, actualPrompt, options);
await createAndConnectRemoteEnvironment(actualPrompt, options);
} catch (error) {
await handleRemoteError(error);
}
Expand Down Expand Up @@ -99,29 +90,12 @@ async function connectToRemoteUrl(
await launchRemoteTUI(remoteUrl, prompt);
}

function requireAccessToken(): string {
const authConfig = loadAuthConfig();

if (!authConfig) {
throw new AuthenticationRequiredError();
}

const accessToken = getAccessToken(authConfig);

if (!accessToken) {
throw new AuthenticationRequiredError();
}

return accessToken;
}

async function connectExistingAgent(
agentId: string,
accessToken: string,
prompt: string | undefined,
startOnly?: boolean,
) {
const tunnel = await fetchAgentTunnel(agentId, accessToken);
const tunnel = await fetchAgentTunnel(agentId);

if (startOnly) {
printStartJson({
Expand All @@ -142,30 +116,24 @@ async function connectExistingAgent(
}

async function createAndConnectRemoteEnvironment(
accessToken: string,
prompt: string | undefined,
options: RemoteCommandOptions,
) {
const requestBody = buildAgentRequestBody(options, prompt);

const response = await fetch(new URL("agents", env.apiBase), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify(requestBody),
});

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to create remote environment: ${response.status} ${errorText}`,
);
let result: AgentCreationResponse;
try {
const response = await post<AgentCreationResponse>("agents", requestBody);
result = response.data;
} catch (error) {
if (error instanceof ApiRequestError) {
throw new Error(
`Failed to create remote environment: ${error.status} ${error.response || error.statusText}`,
);
}
throw error;
}

const result = (await response.json()) as AgentCreationResponse;

if (options.start) {
printStartJson({
status: "success",
Expand Down Expand Up @@ -216,26 +184,18 @@ function buildAgentRequestBody(
return body;
}

async function fetchAgentTunnel(agentId: string, accessToken: string) {
const response = await fetch(
new URL(`agents/${agentId}/tunnel`, env.apiBase),
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${accessToken}`,
},
},
);

if (!response.ok) {
const errorText = await response.text();
throw new Error(
`Failed to create tunnel for agent ${agentId}: ${response.status} ${errorText}`,
);
async function fetchAgentTunnel(agentId: string) {
try {
const response = await post<TunnelResponse>(`agents/${agentId}/tunnel`);
return response.data;
} catch (error) {
if (error instanceof ApiRequestError) {
throw new Error(
`Failed to create tunnel for agent ${agentId}: ${error.status} ${error.response || error.statusText}`,
);
}
throw error;
}

return (await response.json()) as TunnelResponse;
}

async function launchRemoteTUI(remoteUrl: string, prompt: string | undefined) {
Expand Down
6 changes: 6 additions & 0 deletions extensions/cli/src/tools/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import { multiEditTool } from "./multiEdit.js";
import { readFileTool } from "./readFile.js";
import { runTerminalCommandTool } from "./runTerminalCommand.js";
import { searchCodeTool } from "./searchCode.js";
import { statusTool } from "./status.js";
import {
type Tool,
type ToolCall,
Expand Down Expand Up @@ -70,6 +71,11 @@ function getDynamicTools(): Tool[] {
// Service not ready yet, no dynamic tools
}

// Add beta status tool if --beta-status-tool flag is present
if (process.argv.includes("--beta-status-tool")) {
dynamicTools.push(statusTool);
}

return dynamicTools;
}

Expand Down
78 changes: 78 additions & 0 deletions extensions/cli/src/tools/status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import {
ApiRequestError,
AuthenticationRequiredError,
post,
} from "../util/apiClient.js";
import { logger } from "../util/logger.js";

import { Tool } from "./types.js";

/**
* Extract the agent ID from the --id command line flag
*/
function getAgentIdFromArgs(): string | undefined {
const args = process.argv;
const idIndex = args.indexOf("--id");
if (idIndex !== -1 && idIndex + 1 < args.length) {
return args[idIndex + 1];
}
return undefined;
}

export const statusTool: Tool = {
name: "Status",
displayName: "Status",
description: `Set the current status of your task for the user to see

The available statuses are:
- PLANNING: You are creating a plan before beginning implementation
- WORKING: The task is in progress
- DONE: The task is complete
- BLOCKED: You need further information from the user in order to proceed

You should use this tool to notify the user whenever the state of your work changes. By default, the status is assumed to be "PLANNING" prior to you setting a different status.`,
parameters: {
type: "object",
required: ["status"],
properties: {
status: {
type: "string",
description: "The status value to set",
},
},
},
readonly: true,
isBuiltIn: true,
run: async (args: { status: string }): Promise<string> => {
try {
// Get agent ID from --id flag
const agentId = getAgentIdFromArgs();
if (!agentId) {
const errorMessage =
"Agent ID is required. Please use the --id flag with cn serve.";
logger.error(errorMessage);
return `Error: ${errorMessage}`;
}

// Call the API endpoint using shared client
await post(`agents/${agentId}/status`, { status: args.status });

logger.info(`Status: ${args.status}`);
return `Status set: ${args.status}`;
} catch (error) {
if (error instanceof AuthenticationRequiredError) {
logger.error(error.message);
return "Error: Authentication required";
}

if (error instanceof ApiRequestError) {
return `Error setting status: ${error.status} ${error.response || error.statusText}`;
}

const errorMessage =
error instanceof Error ? error.message : String(error);
logger.error(`Error setting status: ${errorMessage}`);
return `Error setting status: ${errorMessage}`;
}
},
};
Loading
Loading