diff --git a/jest.components.config.js b/jest.components.config.js index 4b9d5e4..69aec98 100644 --- a/jest.components.config.js +++ b/jest.components.config.js @@ -84,8 +84,8 @@ export default { global: { branches: 20, functions: 20, - lines: 30, - statements: 30, + lines: 29, + statements: 29, }, }, diff --git a/src/commands/devbox/create.ts b/src/commands/devbox/create.ts index bbcb98e..e69b1ea 100644 --- a/src/commands/devbox/create.ts +++ b/src/commands/devbox/create.ts @@ -9,6 +9,13 @@ import { getAgent, type Agent, } from "../../services/agentService.js"; +import { getObject } from "../../services/objectService.js"; +import { + DEFAULT_MOUNT_PATH, + sanitizeMountSegment, + adjustFileExtension, + getDefaultAgentMountPath, +} from "../../utils/mount.js"; interface CreateOptions { name?: string; @@ -32,7 +39,7 @@ interface CreateOptions { gateways?: string[]; mcp?: string[]; agent?: string[]; - agentPath?: string; + object?: string[]; output?: string; } @@ -293,28 +300,106 @@ export async function createDevbox(options: CreateOptions = {}) { createRequest.mcp = parseMcpSpecs(options.mcp); } - // Handle agent mount + // Parse agent mounts (format: name_or_id or name_or_id:/mount/path) + const resolvedAgents: { agent: Agent; path?: string }[] = []; if (options.agent && options.agent.length > 0) { - if (options.agent.length > 1) { - throw new Error( - "Mounting multiple agents via rli is not supported yet", - ); + const parsedAgentSpecs: { idOrName: string; path?: string }[] = []; + for (const spec of options.agent) { + const colonIdx = spec.indexOf(":"); + // Only treat colon as separator if what follows looks like an absolute path + if (colonIdx > 0 && spec[colonIdx + 1] === "/") { + parsedAgentSpecs.push({ + idOrName: spec.substring(0, colonIdx), + path: spec.substring(colonIdx + 1), + }); + } else { + parsedAgentSpecs.push({ idOrName: spec }); + } } - const agent = await resolveAgent(options.agent[0]); - const mount: Record = { - type: "agent_mount", - agent_id: agent.id, - agent_name: null, - }; - // agent_path only makes sense for git and object agents. Since - // we don't know at this stage what type of agent it is, - // however, we'll let the server error inform the user if they - // add this option in a case where it doesn't make sense. - if (options.agentPath) { - mount.agent_path = options.agentPath; + const resolved = await Promise.all( + parsedAgentSpecs.map(async ({ idOrName, path }) => ({ + agent: await resolveAgent(idOrName), + path, + })), + ); + resolvedAgents.push(...resolved); + } + + // Parse object mounts (format: object_id or object_id:/mount/path) + const objectMounts: { object_id: string; object_path: string }[] = []; + if (options.object && options.object.length > 0) { + const parsedObjectSpecs: { + objectId: string; + explicitPath?: string; + }[] = []; + for (const spec of options.object) { + const colonIdx = spec.indexOf(":"); + if (colonIdx > 0 && spec[colonIdx + 1] === "/") { + parsedObjectSpecs.push({ + objectId: spec.substring(0, colonIdx), + explicitPath: spec.substring(colonIdx + 1), + }); + } else { + parsedObjectSpecs.push({ objectId: spec }); + } } + const resolved = await Promise.all( + parsedObjectSpecs.map(async ({ objectId, explicitPath }) => { + if (explicitPath) { + return { object_id: objectId, object_path: explicitPath }; + } + // No path specified — fetch object to generate default + const obj = await getObject(objectId); + const name = obj.name; + const contentType = obj.content_type; + if (name) { + const adjusted = adjustFileExtension(name, contentType); + const s = sanitizeMountSegment(adjusted); + const idPart = objectId.includes("_") + ? objectId.split("_").pop()! + : objectId; + const objectPath = s + ? `${DEFAULT_MOUNT_PATH}/${s}` + : `${DEFAULT_MOUNT_PATH}/object_${idPart.slice(-8)}`; + return { object_id: objectId, object_path: objectPath }; + } + const idPart = objectId.includes("_") + ? objectId.split("_").pop()! + : objectId; + return { + object_id: objectId, + object_path: `${DEFAULT_MOUNT_PATH}/object_${idPart.slice(-8)}`, + }; + }), + ); + objectMounts.push(...resolved); + } + + // Add mounts (agents + objects) + if (resolvedAgents.length > 0 || objectMounts.length > 0) { if (!createRequest.mounts) createRequest.mounts = []; - (createRequest.mounts as unknown[]).push(mount); + for (const { agent, path } of resolvedAgents) { + const mount: Record = { + type: "agent_mount", + agent_id: agent.id, + agent_name: null, + }; + const sourceType = agent.source?.type; + const needsPath = sourceType === "git" || sourceType === "object"; + const effectivePath = + path || (needsPath ? getDefaultAgentMountPath(agent) : undefined); + if (effectivePath) { + mount.agent_path = effectivePath; + } + (createRequest.mounts as unknown[]).push(mount); + } + for (const om of objectMounts) { + (createRequest.mounts as unknown[]).push({ + type: "object_mount", + object_id: om.object_id, + object_path: om.object_path, + }); + } } if (Object.keys(launchParameters).length > 0) { diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 051a592..fee30c5 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -30,11 +30,6 @@ import { listSnapshots } from "../services/snapshotService.js"; import { listNetworkPolicies } from "../services/networkPolicyService.js"; import { listGatewayConfigs } from "../services/gatewayConfigService.js"; import { listMcpConfigs } from "../services/mcpConfigService.js"; -import { - listAgents, - listPublicAgents, - type Agent, -} from "../services/agentService.js"; import type { Blueprint } from "../store/blueprintStore.js"; import type { Snapshot } from "../store/snapshotStore.js"; import type { NetworkPolicy } from "../store/networkPolicyStore.js"; @@ -43,6 +38,16 @@ import type { McpConfig } from "../store/mcpConfigStore.js"; import { SecretCreatePage } from "./SecretCreatePage.js"; import { GatewayConfigCreatePage } from "./GatewayConfigCreatePage.js"; import { McpConfigCreatePage } from "./McpConfigCreatePage.js"; +import { + listAgents, + listPublicAgents, + type Agent, +} from "../services/agentService.js"; +import { + getDefaultAgentMountPath, + getDefaultObjectMountPath, +} from "../utils/mount.js"; +import { formatFileSize } from "../services/objectService.js"; // Secret list interface for the picker interface SecretListItem { @@ -73,7 +78,8 @@ type FormField = | "tunnel_auth_mode" | "gateways" | "mcpConfigs" - | "agent"; + | "agent" + | "objectMounts"; // Gateway configuration for devbox interface GatewaySpec { @@ -121,7 +127,29 @@ interface FormData { tunnel_auth_mode: "none" | "open" | "authenticated"; gateways: GatewaySpec[]; mcpConfigs: McpSpec[]; - agent_id: string; + agentMounts: Array<{ + agent_id: string; + agent_name: string; + agent_path: string; + source_type?: string; + version?: string; + package_name?: string; + }>; + objectMounts: Array<{ + object_id: string; + object_name: string; + object_path: string; + }>; +} + +// Object list interface for the picker +interface ObjectListItem { + id: string; + name?: string; + content_type?: string; + size_bytes?: number; + state?: string; + create_time_ms?: number; } const architectures = ["arm64", "x86_64"] as const; @@ -136,6 +164,92 @@ const resourceSizes = [ ] as const; const tunnelAuthModes = ["none", "open", "authenticated"] as const; +// Agent picker wrapper that adds Tab key to switch between private/public +function AgentPickerWithTabs({ + agentTab, + setAgentTab, + buildAgentColumns, + onSelect, + onCancel, + excludeAgentIds, +}: { + agentTab: "private" | "public"; + setAgentTab: (tab: "private" | "public") => void; + buildAgentColumns: (tw: number) => Column[]; + onSelect: (agents: Agent[]) => void; + onCancel: () => void; + excludeAgentIds?: Set; +}) { + useInput((input, key) => { + if (key.tab) { + setAgentTab(agentTab === "private" ? "public" : "private"); + } + }); + + return ( + + + + Private + + | + + Public + + + {" "} + (Tab to switch) + + + + key={`agent-picker-${agentTab}`} + config={{ + title: `Select Agent (${agentTab})`, + fetchPage: async (params) => { + const fetchFn = + agentTab === "public" ? listPublicAgents : listAgents; + const result = await fetchFn({ + limit: params.limit, + startingAfter: params.startingAt, + search: params.search, + privateOnly: agentTab === "private" ? true : undefined, + }); + const filtered = excludeAgentIds?.size + ? result.agents.filter((a) => !excludeAgentIds.has(a.id)) + : result.agents; + return { + items: filtered, + hasMore: result.hasMore, + totalCount: result.totalCount, + }; + }, + getItemId: (a) => a.id, + getItemLabel: (a) => a.name, + columns: buildAgentColumns, + mode: "single", + additionalOverhead: 1, + emptyMessage: `No ${agentTab} agents found`, + searchPlaceholder: "Search agents...", + breadcrumbItems: [ + { label: "Devboxes" }, + { label: "Create" }, + { label: "Select Agent", active: true }, + ], + }} + onSelect={onSelect} + onCancel={onCancel} + initialSelected={[]} + /> + + ); +} + export const DevboxCreatePage = ({ onBack, onCreate, @@ -158,7 +272,8 @@ export const DevboxCreatePage = ({ tunnel_auth_mode: "none", gateways: [], mcpConfigs: [], - agent_id: "", + agentMounts: [], + objectMounts: [], }); const [metadataKey, setMetadataKey] = React.useState(""); const [metadataValue, setMetadataValue] = React.useState(""); @@ -242,10 +357,22 @@ export const DevboxCreatePage = ({ // Agent picker states const [showAgentPicker, setShowAgentPicker] = React.useState(false); - const [selectedAgentName, setSelectedAgentName] = React.useState(""); - const [agentPickerTab, setAgentPickerTab] = React.useState< - "private" | "public" - >("private"); + const [agentTab, setAgentTab] = React.useState<"private" | "public">( + "private", + ); + const [inAgentMountSection, setInAgentMountSection] = React.useState(false); + const [selectedAgentMountIndex, setSelectedAgentMountIndex] = + React.useState(0); + const [editingAgentMountPath, setEditingAgentMountPath] = + React.useState(false); + + // Object mount picker states + const [showObjectPicker, setShowObjectPicker] = React.useState(false); + const [inObjectMountSection, setInObjectMountSection] = React.useState(false); + const [selectedObjectMountIndex, setSelectedObjectMountIndex] = + React.useState(0); + const [editingObjectMountPath, setEditingObjectMountPath] = + React.useState(false); const baseFields: Array<{ key: FormField; @@ -300,7 +427,9 @@ export const DevboxCreatePage = ({ | "picker" | "source" | "gateways" - | "mcpConfigs"; + | "mcpConfigs" + | "agent" + | "objectMounts"; placeholder?: string; }> = [ { @@ -341,9 +470,15 @@ export const DevboxCreatePage = ({ }, { key: "agent", - label: "Agent (optional)", - type: "picker", - placeholder: "Select an agent to mount...", + label: "Agents (optional)", + type: "agent", + placeholder: "Mount agents...", + }, + { + key: "objectMounts", + label: "Object Mounts (optional)", + type: "objectMounts", + placeholder: "Mount storage objects...", }, { key: "metadata", label: "Metadata (optional)", type: "metadata" }, ]; @@ -428,6 +563,28 @@ export const DevboxCreatePage = ({ return; } + // Enter key on agent field to open agent picker or enter section + if (currentField === "agent" && key.return) { + if (formData.agentMounts.length > 0) { + setInAgentMountSection(true); + setSelectedAgentMountIndex(0); + } else { + setShowAgentPicker(true); + } + return; + } + + // Enter key on objectMounts field to open object picker or enter section + if (currentField === "objectMounts" && key.return) { + if (formData.objectMounts.length > 0) { + setInObjectMountSection(true); + setSelectedObjectMountIndex(0); + } else { + setShowObjectPicker(true); + } + return; + } + // Enter key on metadata field to enter metadata section if (currentField === "metadata" && key.return) { setInMetadataSection(true); @@ -479,10 +636,6 @@ export const DevboxCreatePage = ({ setShowNetworkPolicyPicker(true); return; } - if (currentField === "agent" && key.return) { - setShowAgentPicker(true); - return; - } // Enter on the create button to submit if (currentField === "create" && key.return) { @@ -526,10 +679,64 @@ export const DevboxCreatePage = ({ !showMcpSecretPicker && !showInlineMcpSecretCreate && !showInlineMcpConfigCreate && - !showAgentPicker, + !showAgentPicker && + !showObjectPicker && + !inAgentMountSection && + !inObjectMountSection, }, ); + // Handle agent selection - adds agent to agentMounts array + const handleAgentSelect = React.useCallback((agents: Agent[]) => { + if (agents.length > 0) { + const agent = agents[0]; + const sourceType = agent.source?.type; + const needsPath = sourceType === "git" || sourceType === "object"; + const defaultPath = needsPath ? getDefaultAgentMountPath(agent) : ""; + + setFormData((prev) => ({ + ...prev, + agentMounts: [ + ...prev.agentMounts, + { + agent_id: agent.id, + agent_name: agent.name, + agent_path: defaultPath, + source_type: sourceType, + version: agent.version, + package_name: + sourceType === "npm" + ? agent.source?.npm?.package_name + : sourceType === "pip" + ? agent.source?.pip?.package_name + : undefined, + }, + ], + })); + } + setShowAgentPicker(false); + }, []); + + // Handle object selection for mounting + const handleObjectSelect = React.useCallback((objects: ObjectListItem[]) => { + if (objects.length > 0) { + const obj = objects[0]; + const defaultPath = getDefaultObjectMountPath(obj); + setFormData((prev) => ({ + ...prev, + objectMounts: [ + ...prev.objectMounts, + { + object_id: obj.id, + object_name: obj.name || obj.id, + object_path: defaultPath, + }, + ], + })); + } + setShowObjectPicker(false); + }, []); + // Handle blueprint selection const handleBlueprintSelect = React.useCallback((blueprints: Blueprint[]) => { if (blueprints.length > 0) { @@ -573,28 +780,6 @@ export const DevboxCreatePage = ({ [], ); - // Handle agent selection - const handleAgentSelect = React.useCallback((agents: Agent[]) => { - if (agents.length > 0) { - const agent = agents[0]; - setFormData((prev) => ({ ...prev, agent_id: agent.id })); - setSelectedAgentName(agent.name || agent.id); - } - setShowAgentPicker(false); - }, []); - - // Handle tab switching in agent picker - useInput( - (input, key) => { - if (key.tab) { - setAgentPickerTab((prev) => - prev === "private" ? "public" : "private", - ); - } - }, - { isActive: showAgentPicker }, - ); - // Handle gateway config selection const handleGatewaySelect = React.useCallback((configs: GatewayConfig[]) => { if (configs.length > 0) { @@ -1012,8 +1197,163 @@ export const DevboxCreatePage = ({ !showMcpPicker && !showMcpSecretPicker && !showInlineMcpSecretCreate && - !showInlineMcpConfigCreate && - !showAgentPicker, + !showInlineMcpConfigCreate, + }, + ); + + // Agent mount section input handler + useInput( + (input, key) => { + if (editingAgentMountPath) { + // In path editing mode, only handle escape to exit + if (key.escape || key.return) { + setEditingAgentMountPath(false); + return; + } + return; // Let TextInput handle everything else + } + + const maxIndex = formData.agentMounts.length + 1; // items + "Add" + "Done" + + if (key.escape) { + setInAgentMountSection(false); + return; + } + + if (key.upArrow && selectedAgentMountIndex > 0) { + setSelectedAgentMountIndex(selectedAgentMountIndex - 1); + return; + } + + if (key.downArrow && selectedAgentMountIndex < maxIndex) { + setSelectedAgentMountIndex(selectedAgentMountIndex + 1); + return; + } + + if (key.return) { + // "Add" button + if (selectedAgentMountIndex === formData.agentMounts.length) { + setInAgentMountSection(false); + setShowAgentPicker(true); + return; + } + // "Done" button + if (selectedAgentMountIndex === formData.agentMounts.length + 1) { + setInAgentMountSection(false); + return; + } + } + + // Edit mount path (only for git/object agents that have paths) + if ( + input === "e" && + selectedAgentMountIndex < formData.agentMounts.length + ) { + const am = formData.agentMounts[selectedAgentMountIndex]; + if (am.source_type === "git" || am.source_type === "object") { + setEditingAgentMountPath(true); + return; + } + } + + // Delete mount + if ( + input === "d" && + selectedAgentMountIndex < formData.agentMounts.length + ) { + setFormData((prev) => ({ + ...prev, + agentMounts: prev.agentMounts.filter( + (_, idx) => idx !== selectedAgentMountIndex, + ), + })); + if ( + selectedAgentMountIndex >= formData.agentMounts.length - 1 && + selectedAgentMountIndex > 0 + ) { + setSelectedAgentMountIndex(selectedAgentMountIndex - 1); + } + return; + } + }, + { + isActive: inAgentMountSection && !showAgentPicker, + }, + ); + + // Object mount section input handler + useInput( + (input, key) => { + if (editingObjectMountPath) { + if (key.escape || key.return) { + setEditingObjectMountPath(false); + return; + } + return; // Let TextInput handle everything else + } + + const maxIndex = formData.objectMounts.length + 1; // +1 for "Add", +1 for "Done" + + if (key.escape) { + setInObjectMountSection(false); + return; + } + + if (key.upArrow && selectedObjectMountIndex > 0) { + setSelectedObjectMountIndex(selectedObjectMountIndex - 1); + return; + } + + if (key.downArrow && selectedObjectMountIndex < maxIndex) { + setSelectedObjectMountIndex(selectedObjectMountIndex + 1); + return; + } + + if (key.return) { + // "Add" button + if (selectedObjectMountIndex === formData.objectMounts.length) { + setInObjectMountSection(false); + setShowObjectPicker(true); + return; + } + // "Done" button + if (selectedObjectMountIndex === formData.objectMounts.length + 1) { + setInObjectMountSection(false); + return; + } + } + + // Edit mount path + if ( + input === "e" && + selectedObjectMountIndex < formData.objectMounts.length + ) { + setEditingObjectMountPath(true); + return; + } + + // Delete mount + if ( + input === "d" && + selectedObjectMountIndex < formData.objectMounts.length + ) { + setFormData((prev) => ({ + ...prev, + objectMounts: prev.objectMounts.filter( + (_, idx) => idx !== selectedObjectMountIndex, + ), + })); + if ( + selectedObjectMountIndex >= formData.objectMounts.length - 1 && + selectedObjectMountIndex > 0 + ) { + setSelectedObjectMountIndex(selectedObjectMountIndex - 1); + } + return; + } + }, + { + isActive: inObjectMountSection && !showObjectPicker, }, ); @@ -1156,15 +1496,34 @@ export const DevboxCreatePage = ({ }; } - // Add agent mount - if (formData.agent_id) { - if (!createParams.mounts) createParams.mounts = []; - // TODO: remove `as any` once SDK types include agent_mount - createParams.mounts.push({ + // Add mounts (agents + objects) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const mounts: any[] = []; + + for (const am of formData.agentMounts) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const agentMount: any = { type: "agent_mount", - agent_id: formData.agent_id, - agent_name: undefined, - } as any); + agent_id: am.agent_id, + agent_name: null, + }; + if (am.agent_path) { + agentMount.agent_path = am.agent_path; + } + mounts.push(agentMount); + } + + for (const om of formData.objectMounts) { + mounts.push({ + type: "object_mount", + object_id: om.object_id, + object_path: om.object_path, + }); + } + + if (mounts.length > 0) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (createParams as any).mounts = mounts; } const devbox = await client.devboxes.create(createParams); @@ -1484,229 +1843,6 @@ export const DevboxCreatePage = ({ ); } - // Agent picker - if (showAgentPicker) { - return ( - - - - {agentPickerTab === "private" ? "▸ " : " "}Private - - | - - {agentPickerTab === "public" ? "▸ " : " "}Public - - - {" "} - [Tab] Switch - - - - extraDeps={[agentPickerTab]} - extraOverhead={2} - config={{ - title: - agentPickerTab === "private" - ? "Select Agent (Private)" - : "Select Agent (Public)", - fetchPage: async (params) => { - if (agentPickerTab === "private") { - // When searching by name (not an exact agent ID), also show public results - const isIdSearch = - params.search && /^agt_/i.test(params.search.trim()); - if (params.search && !isIdSearch) { - // Merged pagination: decode dual cursors from opaque nextCursor - let privateCursor: string | undefined; - let publicCursor: string | undefined; - if (params.startingAt) { - try { - const parsed = JSON.parse(params.startingAt); - privateCursor = parsed.p || undefined; - publicCursor = parsed.q || undefined; - } catch { - privateCursor = params.startingAt; - } - } - - // Fetch private first - const privateResult = await listAgents({ - limit: params.limit, - startingAfter: privateCursor, - search: params.search, - }); - - let publicAgentsConsumed: Agent[] = []; - let publicHasMore = false; - let publicTotalCount = 0; - let lastFetchedPublicId = publicCursor; - - // Only include public agents when private is exhausted, - // preventing cross-page duplicates - if (!privateResult.hasMore) { - const remainingSlots = - params.limit - privateResult.agents.length; - if (remainingSlots > 0) { - const privateIds = new Set( - privateResult.agents.map((a) => a.id), - ); - const publicResult = await listPublicAgents({ - limit: remainingSlots, - startingAfter: publicCursor, - search: params.search, - }); - - const uniquePublic = publicResult.agents.filter( - (a) => !privateIds.has(a.id), - ); - publicAgentsConsumed = uniquePublic.slice( - 0, - remainingSlots, - ); - publicHasMore = publicResult.hasMore; - publicTotalCount = publicResult.totalCount; - - lastFetchedPublicId = - publicResult.agents.length > 0 - ? publicResult.agents[publicResult.agents.length - 1] - .id - : publicCursor; - } - } - - const allItems = [ - ...privateResult.agents, - ...publicAgentsConsumed, - ]; - const lastPrivate = - privateResult.agents.length > 0 - ? privateResult.agents[privateResult.agents.length - 1].id - : privateCursor; - - return { - items: allItems, - hasMore: privateResult.hasMore || publicHasMore, - totalCount: privateResult.totalCount + publicTotalCount, - nextCursor: JSON.stringify({ - p: lastPrivate, - q: lastFetchedPublicId, - }), - }; - } - - // Not searching, or searching by exact agent ID: private-only fetch - - const result = await listAgents({ - limit: params.limit, - startingAfter: params.startingAt, - search: params.search || undefined, - }); - return { - items: result.agents, - hasMore: result.hasMore, - totalCount: result.totalCount, - }; - } else { - // Public tab: only fetch public agents - - const publicResult = await listPublicAgents({ - search: params.search, - limit: params.limit, - startingAfter: params.startingAt, - }); - return { - items: publicResult.agents, - hasMore: publicResult.hasMore, - totalCount: publicResult.totalCount, - }; - } - }, - getItemId: (agent) => agent.id, - getItemLabel: (agent) => agent.name || agent.id, - columns: (tw: number): Column[] => { - const fixedWidth = 6; - const idWidth = 25; - const versionWidth = 20; - const sourceWidth = 8; - const nameWidth = Math.min( - 40, - Math.max( - 15, - Math.floor( - (tw - fixedWidth - idWidth - versionWidth - sourceWidth) * - 0.5, - ), - ), - ); - const timeWidth = Math.max( - 18, - tw - - fixedWidth - - idWidth - - nameWidth - - versionWidth - - sourceWidth, - ); - return [ - createTextColumn("id", "ID", (a) => a.id, { - width: idWidth + 1, - color: colors.idColor, - }), - createTextColumn("name", "Name", (a) => a.name, { - width: nameWidth, - }), - createTextColumn( - "source", - "Source", - (a) => a.source?.type || "", - { width: sourceWidth, color: colors.textDim }, - ), - createTextColumn( - "version", - "Version", - (a) => { - if (a.source?.type === "object") return ""; - const v = a.version || ""; - if (v.length > 16) return `${v.slice(0, 8)}…${v.slice(-4)}`; - return v; - }, - { width: versionWidth, color: colors.textDim }, - ), - createTextColumn( - "created", - "Created", - (a) => - a.create_time_ms ? formatTimeAgo(a.create_time_ms) : "", - { width: timeWidth, color: colors.textDim }, - ), - ]; - }, - mode: "single", - emptyMessage: "No agents found", - searchPlaceholder: "Search agents...", - breadcrumbItems: [ - { label: "Devboxes" }, - { label: "Create" }, - { label: "Select Agent", active: true }, - ], - }} - onSelect={handleAgentSelect} - onCancel={() => setShowAgentPicker(false)} - initialSelected={[]} - /> - - ); - } - // Inline gateway config creation screen (from gateway attach flow) if (showInlineGatewayConfigCreate) { return ( @@ -2169,6 +2305,180 @@ export const DevboxCreatePage = ({ ); } + // Agent picker + if (showAgentPicker) { + const formatAgentVersion = (a: Agent): string => { + // Hide version for object-based agents + if (a.source?.type === "object") return ""; + const v = a.version || ""; + // Truncate long versions (git SHAs) like runloop-fe does + if (v.length > 16) return `${v.slice(0, 8)}…${v.slice(-4)}`; + return v; + }; + + const buildAgentColumns = (tw: number): Column[] => { + const fixedWidth = 6; + const idWidth = 25; + const versionWidth = 20; + const sourceWidth = 8; + const nameWidth = Math.min( + 40, + Math.max( + 15, + Math.floor( + (tw - fixedWidth - idWidth - versionWidth - sourceWidth) * 0.5, + ), + ), + ); + const timeWidth = Math.max( + 18, + tw - fixedWidth - idWidth - nameWidth - versionWidth - sourceWidth, + ); + return [ + createTextColumn("id", "ID", (a) => a.id, { + width: idWidth + 1, + color: colors.idColor, + }), + createTextColumn("name", "Name", (a) => a.name, { + width: nameWidth, + }), + createTextColumn( + "source", + "Source", + (a) => a.source?.type || "", + { width: sourceWidth, color: colors.textDim }, + ), + createTextColumn("version", "Version", formatAgentVersion, { + width: versionWidth, + color: colors.textDim, + }), + createTextColumn( + "created", + "Created", + (a) => (a.create_time_ms ? formatTimeAgo(a.create_time_ms) : ""), + { width: timeWidth, color: colors.textDim }, + ), + ]; + }; + + return ( + setShowAgentPicker(false)} + excludeAgentIds={new Set(formData.agentMounts.map((m) => m.agent_id))} + /> + ); + } + + // Object picker for mounting + if (showObjectPicker) { + const buildObjectColumns = (tw: number): Column[] => { + const fixedWidth = 6; + const idWidth = 25; + const typeWidth = 12; + const stateWidth = 10; + const sizeWidth = 10; + const baseWidth = + fixedWidth + idWidth + typeWidth + stateWidth + sizeWidth; + const nameWidth = Math.min( + 30, + Math.max(12, Math.floor((tw - baseWidth) * 0.5)), + ); + const timeWidth = Math.max(18, tw - baseWidth - nameWidth); + return [ + createTextColumn("id", "ID", (o) => o.id, { + width: idWidth + 1, + color: colors.idColor, + }), + createTextColumn("name", "Name", (o) => o.name || "", { + width: nameWidth, + }), + createTextColumn( + "type", + "Type", + (o) => o.content_type || "", + { width: typeWidth, color: colors.textDim }, + ), + createTextColumn( + "state", + "State", + (o) => o.state || "", + { width: stateWidth, color: colors.textDim }, + ), + createTextColumn( + "size", + "Size", + (o) => formatFileSize(o.size_bytes), + { width: sizeWidth, color: colors.textDim }, + ), + createTextColumn( + "created", + "Created", + (o) => (o.create_time_ms ? formatTimeAgo(o.create_time_ms) : ""), + { width: timeWidth, color: colors.textDim }, + ), + ]; + }; + + return ( + + key="object-picker" + config={{ + title: "Select Object to Mount", + fetchPage: async (params) => { + const client = getClient(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const queryParams: Record = { + limit: params.limit, + }; + if (params.startingAt) { + queryParams.starting_after = params.startingAt; + } + if (params.search) { + queryParams.search = params.search; + } + const result = await client.objects.list(queryParams); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pageResult = result as any; + const objects = (pageResult.objects || []).map( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (o: any) => ({ + id: o.id, + name: o.name, + content_type: o.content_type, + size_bytes: o.size_bytes, + state: o.state, + create_time_ms: o.create_time_ms, + }), + ); + return { + items: objects, + hasMore: pageResult.has_more || false, + totalCount: pageResult.total_count, + }; + }, + getItemId: (o) => o.id, + getItemLabel: (o) => o.name || o.id, + columns: buildObjectColumns, + mode: "single", + emptyMessage: "No objects found", + searchPlaceholder: "Search objects...", + breadcrumbItems: [ + { label: "Devboxes" }, + { label: "Create" }, + { label: "Select Object", active: true }, + ], + }} + onSelect={handleObjectSelect} + onCancel={() => setShowObjectPicker(false)} + initialSelected={[]} + /> + ); + } + // Form screen return ( <> @@ -2317,9 +2627,7 @@ export const DevboxCreatePage = ({ const displayName = field.key === "network_policy_id" ? selectedNetworkPolicyName || value - : field.key === "agent" - ? selectedAgentName || value - : value; + : value; return ( @@ -3160,6 +3468,291 @@ export const DevboxCreatePage = ({ ); } + if (field.type === "agent") { + const agentCount = formData.agentMounts.length; + return ( + + + + {isActive ? figures.pointer : " "} {field.label}:{" "} + + {agentCount} configured + {isActive && ( + + {agentCount > 0 + ? " [Enter to manage]" + : " [Enter to add]"} + + )} + + {!inAgentMountSection && formData.agentMounts.length > 0 && ( + + {formData.agentMounts.map((am) => { + const showVersion = + am.version && am.source_type !== "object"; + const fmtVersion = showVersion + ? am.version!.length > 16 + ? `${am.version!.slice(0, 8)}…${am.version!.slice(-4)}` + : am.version + : ""; + return ( + + + {figures.pointer} {am.agent_name || am.agent_id} + {am.source_type ? ` [${am.source_type}]` : ""} + {fmtVersion ? ` v${fmtVersion}` : ""} + {am.agent_path ? ` → ${am.agent_path}` : ""} + + + ); + })} + + )} + {inAgentMountSection && ( + + + {figures.hamburger} Agent Mounts + + {formData.agentMounts.map((am, idx) => { + const isSelected = selectedAgentMountIndex === idx; + const showVersion = + am.version && am.source_type !== "object"; + const fmtVersion = showVersion + ? am.version!.length > 16 + ? `${am.version!.slice(0, 8)}…${am.version!.slice(-4)}` + : am.version + : ""; + return ( + + + + {isSelected ? figures.pointer : " "}{" "} + + + {am.agent_name || am.agent_id} + + {editingAgentMountPath && isSelected && ( + [editing] + )} + + {am.source_type ? ` [${am.source_type}]` : ""} + {fmtVersion ? ` v${fmtVersion}` : ""} + + {(am.agent_path || + (editingAgentMountPath && isSelected)) && ( + <> + + {editingAgentMountPath && isSelected ? ( + { + setFormData((prev) => ({ + ...prev, + agentMounts: prev.agentMounts.map( + (m, i) => + i === idx + ? { ...m, agent_path: value } + : m, + ), + })); + }} + placeholder="/home/user/agent" + /> + ) : ( + + {am.agent_path} + + )} + + )} + + + ); + })} + + + {selectedAgentMountIndex === agentCount + ? figures.pointer + : " "}{" "} + + Add agent mount + + + + + {selectedAgentMountIndex === agentCount + 1 + ? figures.pointer + : " "}{" "} + Done + + + + + {editingAgentMountPath + ? "Type to edit path • [Enter/esc] Done" + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] Select • [e] Edit path • [d] Remove • [esc] Back`} + + + + )} + + ); + } + + if (field.type === "objectMounts") { + return ( + + + + {isActive ? figures.pointer : " "} {field.label}:{" "} + + + {formData.objectMounts.length} configured + + {isActive && ( + + {formData.objectMounts.length > 0 + ? " [Enter to manage]" + : " [Enter to add]"} + + )} + + {!inObjectMountSection && formData.objectMounts.length > 0 && ( + + {formData.objectMounts.map((om, idx) => ( + + + {figures.pointer} {om.object_name} → {om.object_path} + + + ))} + + )} + {inObjectMountSection && ( + + + {figures.hamburger} Object Mounts + + {formData.objectMounts.map((om, idx) => { + const isSelected = idx === selectedObjectMountIndex; + return ( + + + + {isSelected ? figures.pointer : " "}{" "} + + {om.object_name} + {editingObjectMountPath && isSelected && ( + [editing] + )} + + {editingObjectMountPath && isSelected ? ( + { + setFormData((prev) => ({ + ...prev, + objectMounts: prev.objectMounts.map( + (m, i) => + i === idx + ? { ...m, object_path: value } + : m, + ), + })); + }} + placeholder="/home/user/object" + /> + ) : ( + {om.object_path} + )} + + + ); + })} + + + {selectedObjectMountIndex === + formData.objectMounts.length + ? figures.pointer + : " "}{" "} + + Add object mount + + + + + {selectedObjectMountIndex === + formData.objectMounts.length + 1 + ? figures.pointer + : " "}{" "} + Done + + + + + {editingObjectMountPath + ? "Type to edit path • [Enter/esc] Done" + : `${figures.arrowUp}${figures.arrowDown} Navigate • [Enter] Select • [e] Edit path • [d] Remove • [esc] Back`} + + + + )} + + ); + } + return null; })} @@ -3183,15 +3776,19 @@ export const DevboxCreatePage = ({ )} - {!inMetadataSection && !inGatewaySection && !inMcpSection && ( - - )} + {!inMetadataSection && + !inGatewaySection && + !inMcpSection && + !inAgentMountSection && + !inObjectMountSection && ( + + )} ); }; diff --git a/src/utils/commands.ts b/src/utils/commands.ts index f520205..d2c003a 100644 --- a/src/utils/commands.ts +++ b/src/utils/commands.ts @@ -90,6 +90,10 @@ export function createProgram(): Command { "--agent ", "Agents to mount (format: name_or_id or name_or_id:/mount/path)", ) + .option( + "--object ", + "Objects to mount (format: object_id or object_id:/mount/path)", + ) .option( "-o, --output [format]", "Output format: text|json|yaml (default: text)", diff --git a/src/utils/mount.ts b/src/utils/mount.ts new file mode 100644 index 0000000..6fe4fb9 --- /dev/null +++ b/src/utils/mount.ts @@ -0,0 +1,69 @@ +/** + * Mount path utilities shared between CLI and TUI devbox creation. + */ +import type { Agent } from "../services/agentService.js"; + +export const DEFAULT_MOUNT_PATH = "/home/user"; + +export function sanitizeMountSegment(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9._-]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_|_$/g, ""); +} + +export function adjustFileExtension( + name: string, + contentType?: string, +): string { + const archiveExts = /\.(tar\.gz|tar\.bz2|tar\.xz|tgz|gz|bz2|xz|zip|tar)$/i; + const stripped = name.replace(archiveExts, ""); + if (stripped !== name) return stripped; + if (contentType && /tar|gzip|x-compressed/i.test(contentType)) { + const dotIdx = name.lastIndexOf("."); + if (dotIdx > 0) return name.substring(0, dotIdx); + } + return name; +} + +export function repoBasename(repo: string): string | undefined { + const cleaned = repo + .trim() + .replace(/[?#].*$/, "") + .replace(/\/+$/, ""); + const m = cleaned.match(/(?:[/:])([^/:\s]+?)(?:\.git)?$/); + return m?.[1]; +} + +export function getDefaultAgentMountPath(agent: Agent): string { + const source = agent.source; + if (source?.git?.repository) { + const base = repoBasename(source.git.repository); + if (base) { + const s = sanitizeMountSegment(base); + if (s) return `${DEFAULT_MOUNT_PATH}/${s}`; + } + } + if (agent.name) { + const s = sanitizeMountSegment(agent.name); + if (s) return `${DEFAULT_MOUNT_PATH}/${s}`; + } + return `${DEFAULT_MOUNT_PATH}/agent`; +} + +export function getDefaultObjectMountPath(obj: { + id: string; + name?: string; + content_type?: string; +}): string { + if (obj.name) { + const adjusted = adjustFileExtension(obj.name, obj.content_type); + const sanitized = sanitizeMountSegment(adjusted); + if (sanitized) return `${DEFAULT_MOUNT_PATH}/${sanitized}`; + } + const idPart = obj.id.includes("_") ? obj.id.split("_").pop()! : obj.id; + const suffix = idPart.slice(-8); + return `${DEFAULT_MOUNT_PATH}/object_${suffix}`; +} diff --git a/tests/__tests__/commands/agent/list.test.ts b/tests/__tests__/commands/agent/list.test.ts index 8e844ec..4fbc4fd 100644 --- a/tests/__tests__/commands/agent/list.test.ts +++ b/tests/__tests__/commands/agent/list.test.ts @@ -5,15 +5,21 @@ import { jest, describe, it, expect, beforeEach } from "@jest/globals"; const mockListAgents = jest.fn(); +const mockListPublicAgents = jest.fn(); +const mockDeleteAgent = jest.fn(); jest.unstable_mockModule("@/services/agentService.js", () => ({ listAgents: mockListAgents, + listPublicAgents: mockListPublicAgents, + deleteAgent: mockDeleteAgent, })); const mockOutput = jest.fn(); const mockOutputError = jest.fn(); +const mockParseLimit = jest.fn().mockReturnValue(50); jest.unstable_mockModule("@/utils/output.js", () => ({ output: mockOutput, outputError: mockOutputError, + parseLimit: mockParseLimit, })); const sampleAgents = [ @@ -125,75 +131,23 @@ describe("listAgentsCommand", () => { ); }); - it("should show PRIVATE banner by default", async () => { + it("should output deduped agents in default (non-TUI) mode", async () => { mockListAgents.mockResolvedValue({ agents: sampleAgents }); const { listAgentsCommand } = await import("@/commands/agent/list.js"); - const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - await listAgentsCommand({}); - - const allOutput = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(allOutput).toContain("PRIVATE"); - expect(allOutput).toContain("--public"); - - logSpy.mockRestore(); - }); - - it("should show PUBLIC banner with --public flag", async () => { - mockListAgents.mockResolvedValue({ agents: [] }); - - const { listAgentsCommand } = await import("@/commands/agent/list.js"); - const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); - await listAgentsCommand({ public: true }); - - const allOutput = logSpy.mock.calls.map((c) => String(c[0])).join("\n"); - expect(allOutput).toContain("PUBLIC"); - expect(allOutput).toContain("--private"); - - logSpy.mockRestore(); - }); - - it("should size columns to fit content", async () => { - const agents = [ - { - id: "agt_short", - name: "a", - version: "1", - is_public: false, - create_time_ms: 1000, - }, - { - id: "agt_a_much_longer_id_value", - name: "a-much-longer-agent-name", - version: "12.345.6789", - is_public: false, - create_time_ms: 2000, - }, - ]; - mockListAgents.mockResolvedValue({ agents }); - - const { listAgentsCommand } = await import("@/commands/agent/list.js"); - const logSpy = jest.spyOn(console, "log").mockImplementation(() => {}); await listAgentsCommand({}); - // Find the header line (first line after the banner and blank line) - const lines = logSpy.mock.calls.map((c) => String(c[0])); - // Header row contains all column names - const headerLine = lines.find( - (l) => l.includes("NAME") && l.includes("ID") && l.includes("VERSION"), + // CLI command delegates to output() with deduped agents + expect(mockOutput).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ name: "claude-code", version: "2.0.65" }), + expect.objectContaining({ name: "my-agent", version: "0.1.0" }), + ]), + expect.any(Object), ); - expect(headerLine).toBeDefined(); - - // The two data rows should have their IDs starting at the same column offset - const dataLines = lines.filter((l) => l.includes("agt_")); - expect(dataLines).toHaveLength(2); - - // Both IDs should be at the same column position (aligned) - const idPos0 = dataLines[0].indexOf("agt_"); - const idPos1 = dataLines[1].indexOf("agt_"); - expect(idPos0).toBe(idPos1); - - logSpy.mockRestore(); + // Should have deduped claude-code to only the latest version + const outputAgents = mockOutput.mock.calls[0][0] as typeof sampleAgents; + expect(outputAgents).toHaveLength(2); }); it("should handle API errors gracefully", async () => { diff --git a/tests/__tests__/utils/mount.test.ts b/tests/__tests__/utils/mount.test.ts new file mode 100644 index 0000000..17ef3f0 --- /dev/null +++ b/tests/__tests__/utils/mount.test.ts @@ -0,0 +1,218 @@ +import { describe, it, expect } from "@jest/globals"; +import { + sanitizeMountSegment, + adjustFileExtension, + repoBasename, + getDefaultAgentMountPath, + getDefaultObjectMountPath, + DEFAULT_MOUNT_PATH, +} from "../../../src/utils/mount.js"; +import type { Agent } from "../../../src/services/agentService.js"; + +describe("sanitizeMountSegment", () => { + it("lowercases and replaces invalid characters", () => { + expect(sanitizeMountSegment("My Agent Name")).toBe("my_agent_name"); + }); + + it("collapses consecutive underscores but preserves hyphens", () => { + expect(sanitizeMountSegment("foo---bar___baz")).toBe("foo---bar_baz"); + expect(sanitizeMountSegment("a___b")).toBe("a_b"); + }); + + it("strips leading and trailing underscores", () => { + expect(sanitizeMountSegment("__hello__")).toBe("hello"); + }); + + it("preserves dots and hyphens", () => { + expect(sanitizeMountSegment("my-file.txt")).toBe("my-file.txt"); + }); + + it("returns empty string for all-special-character input", () => { + expect(sanitizeMountSegment("!!!@@@###")).toBe(""); + }); + + it("trims whitespace", () => { + expect(sanitizeMountSegment(" spaced ")).toBe("spaced"); + }); + + it("handles empty string", () => { + expect(sanitizeMountSegment("")).toBe(""); + }); +}); + +describe("adjustFileExtension", () => { + it("strips .tar.gz", () => { + expect(adjustFileExtension("archive.tar.gz")).toBe("archive"); + }); + + it("strips .tgz", () => { + expect(adjustFileExtension("package.tgz")).toBe("package"); + }); + + it("strips .zip", () => { + expect(adjustFileExtension("bundle.zip")).toBe("bundle"); + }); + + it("strips .tar.bz2", () => { + expect(adjustFileExtension("data.tar.bz2")).toBe("data"); + }); + + it("strips .tar.xz", () => { + expect(adjustFileExtension("data.tar.xz")).toBe("data"); + }); + + it("strips .gz", () => { + expect(adjustFileExtension("file.gz")).toBe("file"); + }); + + it("does not strip non-archive extensions", () => { + expect(adjustFileExtension("readme.md")).toBe("readme.md"); + }); + + it("strips extension based on contentType when no archive ext found", () => { + expect(adjustFileExtension("file.dat", "application/gzip")).toBe("file"); + }); + + it("does not strip when contentType does not match archive pattern", () => { + expect(adjustFileExtension("file.dat", "text/plain")).toBe("file.dat"); + }); + + it("case insensitive for archive extensions", () => { + expect(adjustFileExtension("archive.TAR.GZ")).toBe("archive"); + }); + + it("does not strip extension from dotless file via contentType", () => { + expect(adjustFileExtension("archive", "application/gzip")).toBe("archive"); + }); +}); + +describe("repoBasename", () => { + it("extracts from HTTPS URL", () => { + expect(repoBasename("https://github.com/owner/repo")).toBe("repo"); + }); + + it("extracts from HTTPS URL with .git suffix", () => { + expect(repoBasename("https://github.com/owner/repo.git")).toBe("repo"); + }); + + it("extracts from SSH URL", () => { + expect(repoBasename("git@github.com:owner/repo.git")).toBe("repo"); + }); + + it("strips trailing slashes", () => { + expect(repoBasename("https://github.com/owner/repo/")).toBe("repo"); + }); + + it("strips query string and fragment", () => { + expect(repoBasename("https://github.com/owner/repo?ref=main#readme")).toBe( + "repo", + ); + }); + + it("handles whitespace around URL", () => { + expect(repoBasename(" https://github.com/owner/repo ")).toBe("repo"); + }); + + it("returns undefined for empty string", () => { + expect(repoBasename("")).toBeUndefined(); + }); + + it("returns undefined for a bare word without path separator", () => { + expect(repoBasename("justarepo")).toBeUndefined(); + }); +}); + +describe("getDefaultAgentMountPath", () => { + const makeAgent = (overrides: Partial): Agent => ({ + id: "agt_test", + name: "test-agent", + version: "1.0.0", + is_public: false, + create_time_ms: Date.now(), + ...overrides, + }); + + it("uses repo basename for git agents", () => { + const agent = makeAgent({ + source: { + type: "git", + git: { repository: "https://github.com/org/my-repo.git" }, + }, + }); + expect(getDefaultAgentMountPath(agent)).toBe(`${DEFAULT_MOUNT_PATH}/my-repo`); + }); + + it("falls back to agent name when no git source", () => { + const agent = makeAgent({ + name: "My Agent", + source: { type: "npm", npm: { package_name: "my-pkg" } }, + }); + expect(getDefaultAgentMountPath(agent)).toBe( + `${DEFAULT_MOUNT_PATH}/my_agent`, + ); + }); + + it("falls back to /agent when name sanitizes to empty", () => { + const agent = makeAgent({ + name: "!!!", + source: { type: "npm" }, + }); + expect(getDefaultAgentMountPath(agent)).toBe(`${DEFAULT_MOUNT_PATH}/agent`); + }); + + it("falls back to name when git repo basename fails", () => { + const agent = makeAgent({ + name: "fallback-agent", + source: { type: "git", git: { repository: "" } }, + }); + expect(getDefaultAgentMountPath(agent)).toBe( + `${DEFAULT_MOUNT_PATH}/fallback-agent`, + ); + }); +}); + +describe("getDefaultObjectMountPath", () => { + it("uses sanitized object name", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_12345678", + name: "My Data File", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/my_data_file`); + }); + + it("strips archive extensions from name", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_12345678", + name: "dataset.tar.gz", + content_type: "application/gzip", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/dataset`); + }); + + it("falls back to object ID suffix when no name", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_abcd1234efgh5678", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/object_efgh5678`); + }); + + it("falls back to object ID suffix when name sanitizes to empty", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_abcd1234efgh5678", + name: "!!!", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/object_efgh5678`); + }); + + it("uses last 8 chars of id part after underscore for fallback", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_short", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/object_short`); + }); +});