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/agent/create.ts b/src/commands/agent/create.ts index bf18717..cc34048 100644 --- a/src/commands/agent/create.ts +++ b/src/commands/agent/create.ts @@ -7,7 +7,7 @@ import { output, outputError } from "../../utils/output.js"; interface CreateOptions { name: string; - agentVersion: string; + agentVersion?: string; source: string; package?: string; registryUrl?: string; @@ -103,7 +103,7 @@ export async function createAgentCommand( const agent = await createAgent({ name: options.name, - version: options.agentVersion, + ...(options.agentVersion ? { version: options.agentVersion } : {}), source: { type: sourceType, [sourceType]: sourceOptions }, }); diff --git a/src/commands/agent/list.tsx b/src/commands/agent/list.tsx index f5706a9..13a78e3 100644 --- a/src/commands/agent/list.tsx +++ b/src/commands/agent/list.tsx @@ -67,12 +67,18 @@ const columns: ColumnDef[] = [ { header: "VERSION", raw: (a) => { + if (a.source?.type === "object") return "-"; + const v = a.version || a.source?.git?.ref || ""; + if (!v) return "-"; const pkg = a.source?.npm?.package_name || a.source?.pip?.package_name; - return pkg ? `${pkg}@${a.version}` : a.version; + return pkg ? `${pkg}@${v}` : v; }, styled(a) { + if (a.source?.type === "object") return "-"; + const v = a.version || a.source?.git?.ref || ""; + if (!v) return "-"; const pkg = a.source?.npm?.package_name || a.source?.pip?.package_name; - return pkg ? chalk.dim(pkg + "@") + a.version : a.version; + return pkg ? chalk.dim(pkg + "@") + v : v; }, }, { @@ -325,8 +331,9 @@ export const ListAgentsUI = ({ "version", "Version", (a: Agent) => { - if (a.source?.type === "object") return ""; - const v = a.version || ""; + if (a.source?.type === "object") return "-"; + const v = a.version || a.source?.git?.ref || ""; + if (!v) return "-"; if (v.length > 16) return `${v.slice(0, 8)}…${v.slice(-4)}`; return v; }, @@ -734,7 +741,7 @@ export const ListAgentsUI = ({ {showPopup && selectedAgentItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/axon/list.tsx b/src/commands/axon/list.tsx index 3f3d6e0..9e98685 100644 --- a/src/commands/axon/list.tsx +++ b/src/commands/axon/list.tsx @@ -454,7 +454,7 @@ export const ListAxonsUI = ({ {showPopup && selectedAxonItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/blueprint/list.tsx b/src/commands/blueprint/list.tsx index c64f025..746f2a5 100644 --- a/src/commands/blueprint/list.tsx +++ b/src/commands/blueprint/list.tsx @@ -969,7 +969,7 @@ const ListBlueprintsUI = ({ {showPopup && selectedBlueprintItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/devbox/create.ts b/src/commands/devbox/create.ts index bbcb98e..60afca3 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,100 @@ 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 objectPath = s + ? `${DEFAULT_MOUNT_PATH}/${s}` + : `${DEFAULT_MOUNT_PATH}/object_${objectId.slice(-8)}`; + return { object_id: objectId, object_path: objectPath }; + } + return { + object_id: objectId, + object_path: `${DEFAULT_MOUNT_PATH}/object_${objectId.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/commands/devbox/list.tsx b/src/commands/devbox/list.tsx index 7f33a87..20de58b 100644 --- a/src/commands/devbox/list.tsx +++ b/src/commands/devbox/list.tsx @@ -797,7 +797,7 @@ const ListDevboxesUI = ({ {showPopup && selectedDevbox && ( setShowPopup(false)} diff --git a/src/commands/gateway-config/list.tsx b/src/commands/gateway-config/list.tsx index 535f6a4..0965046 100644 --- a/src/commands/gateway-config/list.tsx +++ b/src/commands/gateway-config/list.tsx @@ -740,7 +740,7 @@ const ListGatewayConfigsUI = ({ {showPopup && selectedConfigItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/mcp-config/list.tsx b/src/commands/mcp-config/list.tsx index 8215194..4fe13af 100644 --- a/src/commands/mcp-config/list.tsx +++ b/src/commands/mcp-config/list.tsx @@ -664,7 +664,7 @@ const ListMcpConfigsUI = ({ {showPopup && selectedConfigItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/network-policy/list.tsx b/src/commands/network-policy/list.tsx index 09ede63..d0e3f57 100644 --- a/src/commands/network-policy/list.tsx +++ b/src/commands/network-policy/list.tsx @@ -769,7 +769,7 @@ const ListNetworkPoliciesUI = ({ {showPopup && selectedPolicyItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/object/list.tsx b/src/commands/object/list.tsx index 5a1cf1b..988b558 100644 --- a/src/commands/object/list.tsx +++ b/src/commands/object/list.tsx @@ -809,7 +809,7 @@ const ListObjectsUI = ({ {showPopup && selectedObjectItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/secret/list.tsx b/src/commands/secret/list.tsx index 725deac..58e7e56 100644 --- a/src/commands/secret/list.tsx +++ b/src/commands/secret/list.tsx @@ -611,7 +611,7 @@ const ListSecretsUI = ({ {showPopup && selectedSecretItem && ( ({ key: op.key, label: op.label, diff --git a/src/commands/snapshot/list.tsx b/src/commands/snapshot/list.tsx index 7390ca1..153f288 100644 --- a/src/commands/snapshot/list.tsx +++ b/src/commands/snapshot/list.tsx @@ -682,7 +682,7 @@ const ListSnapshotsUI = ({ {showPopup && selectedSnapshotItem && ( ({ key: op.key, label: op.label, diff --git a/src/components/ActionsPopup.tsx b/src/components/ActionsPopup.tsx index 15587ac..b39db66 100644 --- a/src/components/ActionsPopup.tsx +++ b/src/components/ActionsPopup.tsx @@ -11,7 +11,7 @@ interface ResourceWithId { } interface ActionsPopupProps { - resource: ResourceWithId; + devbox: ResourceWithId; operations: Array<{ key: string; label: string; @@ -24,7 +24,7 @@ interface ActionsPopupProps { } export const ActionsPopup = ({ - resource: _resource, + devbox: _devbox, operations, selectedOperation, onClose: _onClose, diff --git a/src/components/DevboxCreatePage.tsx b/src/components/DevboxCreatePage.tsx index 051a592..cd44ded 100644 --- a/src/components/DevboxCreatePage.tsx +++ b/src/components/DevboxCreatePage.tsx @@ -15,6 +15,7 @@ import { Breadcrumb } from "./Breadcrumb.js"; import { NavigationTips } from "./NavigationTips.js"; import { MetadataDisplay } from "./MetadataDisplay.js"; import { ResourcePicker, createTextColumn, Column } from "./ResourcePicker.js"; +import { ObjectPicker, type ObjectListItem } from "./ObjectPicker.js"; import { formatTimeAgo } from "./ResourceListView.js"; import { getStatusDisplay } from "./StatusBadge.js"; import { @@ -30,11 +31,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 +39,16 @@ import type { McpConfig } from "../store/mcpConfigStore.js"; import { SecretCreatePage } from "./SecretCreatePage.js"; import { GatewayConfigCreatePage } from "./GatewayConfigCreatePage.js"; import { McpConfigCreatePage } from "./McpConfigCreatePage.js"; +import { + getAgent, + listAgents, + listPublicAgents, + type Agent, +} from "../services/agentService.js"; +import { + getDefaultAgentMountPath, + getDefaultObjectMountPath, +} from "../utils/mount.js"; // Secret list interface for the picker interface SecretListItem { @@ -56,6 +62,7 @@ interface DevboxCreatePageProps { onCreate?: (devbox: DevboxView) => void; initialBlueprintId?: string; initialSnapshotId?: string; + initialAgentId?: string; } type FormField = @@ -73,7 +80,8 @@ type FormField = | "tunnel_auth_mode" | "gateways" | "mcpConfigs" - | "agent"; + | "agent" + | "objectMounts"; // Gateway configuration for devbox interface GatewaySpec { @@ -121,7 +129,19 @@ 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; + }>; } const architectures = ["arm64", "x86_64"] as const; @@ -136,11 +156,98 @@ 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, initialBlueprintId, initialSnapshotId, + initialAgentId, }: DevboxCreatePageProps) => { const [currentField, setCurrentField] = React.useState("create"); const [formData, setFormData] = React.useState({ @@ -158,7 +265,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 +350,66 @@ 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); + + // Load initial agent if provided (e.g., from "Create Devbox" on agent detail) + React.useEffect(() => { + if (!initialAgentId) return; + let cancelled = false; + getAgent(initialAgentId) + .then((agent) => { + if (cancelled) return; + setFormData((prev) => { + // Skip if this agent is already mounted + if (prev.agentMounts.some((m) => m.agent_id === agent.id)) { + return prev; + } + const source = agent.source; + const sourceType = source?.type; + const needsPath = sourceType === "git" || sourceType === "object"; + return { + ...prev, + agentMounts: [ + ...prev.agentMounts, + { + agent_id: agent.id, + agent_name: agent.name, + agent_path: needsPath ? getDefaultAgentMountPath(agent) : "", + source_type: sourceType, + version: agent.version, + package_name: + sourceType === "npm" + ? source?.npm?.package_name + : sourceType === "pip" + ? source?.pip?.package_name + : undefined, + }, + ], + }; + }); + }) + .catch(() => { + /* silently ignore — agent may not be accessible */ + }); + return () => { + cancelled = true; + }; + }, [initialAgentId]); const baseFields: Array<{ key: FormField; @@ -300,7 +464,9 @@ export const DevboxCreatePage = ({ | "picker" | "source" | "gateways" - | "mcpConfigs"; + | "mcpConfigs" + | "agent" + | "objectMounts"; placeholder?: string; }> = [ { @@ -341,9 +507,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 +600,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 +673,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 +716,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 +817,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 +1234,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 +1533,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 +1880,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 +2342,92 @@ 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) { + return ( + setShowObjectPicker(false)} + initialSelected={[]} + /> + ); + } + // Form screen return ( <> @@ -2317,9 +2576,7 @@ export const DevboxCreatePage = ({ const displayName = field.key === "network_policy_id" ? selectedNetworkPolicyName || value - : field.key === "agent" - ? selectedAgentName || value - : value; + : value; return ( @@ -3160,6 +3417,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 +3725,19 @@ export const DevboxCreatePage = ({ )} - {!inMetadataSection && !inGatewaySection && !inMcpSection && ( - - )} + {!inMetadataSection && + !inGatewaySection && + !inMcpSection && + !inAgentMountSection && + !inObjectMountSection && ( + + )} ); }; diff --git a/src/components/ObjectPicker.tsx b/src/components/ObjectPicker.tsx new file mode 100644 index 0000000..ea913ed --- /dev/null +++ b/src/components/ObjectPicker.tsx @@ -0,0 +1,168 @@ +/** + * ObjectPicker - Reusable component for selecting storage objects + * Wraps ResourcePicker with object-specific configuration + */ +import React from "react"; +import { + ResourcePicker, + createTextColumn, + type Column, +} from "./ResourcePicker.js"; +import { formatTimeAgo } from "./ResourceListView.js"; +import { getClient } from "../utils/client.js"; +import { formatFileSize } from "../services/objectService.js"; +import { colors } from "../utils/theme.js"; +import type { BreadcrumbItem } from "./Breadcrumb.js"; + +export interface ObjectListItem { + id: string; + name?: string; + content_type?: string; + size_bytes?: number; + state?: string; + create_time_ms?: number; +} + +export interface ObjectPickerProps { + /** Called when object(s) are selected */ + onSelect: (objects: ObjectListItem[]) => void; + /** Called when picker is cancelled */ + onCancel: () => void; + /** Selection mode - single or multi */ + mode?: "single" | "multi"; + /** Title for the picker */ + title?: string; + /** Breadcrumb items */ + breadcrumbItems?: BreadcrumbItem[]; + /** Initially selected object IDs */ + initialSelected?: string[]; + /** Additional lines of overhead from wrapper components (e.g., tab headers) */ + additionalOverhead?: number; +} + +/** + * Build columns for object picker table + */ +function 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 }, + ), + ]; +} + +/** + * Fetch a page of objects from the API + */ +async function fetchObjectsPage(params: { + limit: number; + startingAt?: string; + search?: string; +}): Promise<{ + items: ObjectListItem[]; + hasMore: boolean; + totalCount?: number; +}> { + 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, + }; +} + +/** + * ObjectPicker component for selecting storage objects + */ +export function ObjectPicker({ + onSelect, + onCancel, + mode = "single", + title = "Select Object", + breadcrumbItems, + initialSelected = [], + additionalOverhead, +}: ObjectPickerProps) { + return ( + + key="object-picker" + config={{ + title, + fetchPage: fetchObjectsPage, + getItemId: (o) => o.id, + getItemLabel: (o) => o.name || o.id, + columns: buildObjectColumns, + mode, + emptyMessage: "No objects found", + searchPlaceholder: "Search objects...", + breadcrumbItems, + additionalOverhead, + }} + onSelect={onSelect} + onCancel={onCancel} + initialSelected={initialSelected} + /> + ); +} diff --git a/src/components/ResourceActionsMenu.tsx b/src/components/ResourceActionsMenu.tsx index 1a21c50..1d46147 100644 --- a/src/components/ResourceActionsMenu.tsx +++ b/src/components/ResourceActionsMenu.tsx @@ -223,7 +223,7 @@ export const ResourceActionsMenu = (props: ResourceActionsMenuProps) => { ({ key: op.key, label: op.label, diff --git a/src/screens/AgentCreateScreen.tsx b/src/screens/AgentCreateScreen.tsx index 3d664c2..c1de44d 100644 --- a/src/screens/AgentCreateScreen.tsx +++ b/src/screens/AgentCreateScreen.tsx @@ -22,13 +22,14 @@ import { FormActionButton, useFormSelectNavigation, } from "../components/form/index.js"; +import { ObjectPicker } from "../components/ObjectPicker.js"; import { colors } from "../utils/theme.js"; import { useExitOnCtrlC } from "../hooks/useExitOnCtrlC.js"; const SOURCE_TYPES = ["npm", "pip", "git", "object"] as const; type SourceType = (typeof SOURCE_TYPES)[number]; -type FormField = +type AgentFormField = | "name" | "version" | "sourceType" @@ -40,28 +41,30 @@ type FormField = | "create"; interface FieldDef { - key: FormField; + key: AgentFormField; label: string; } /** Fields that are always shown */ const baseFields: FieldDef[] = [ { key: "name", label: "Name (required)" }, - { key: "version", label: "Version (required)" }, { key: "sourceType", label: "Source Type" }, ]; /** Source-type-specific fields */ const sourceFields: Record = { npm: [ + { key: "version", label: "Version (optional)" }, { key: "packageName", label: "Package Name (required)" }, { key: "registryUrl", label: "Registry URL (optional)" }, ], pip: [ + { key: "version", label: "Version (optional)" }, { key: "packageName", label: "Package Name (required)" }, { key: "registryUrl", label: "Registry URL (optional)" }, ], git: [ + { key: "version", label: "Version (optional)" }, { key: "repository", label: "Repository URL (required)" }, { key: "ref", label: "Ref (optional)" }, ], @@ -78,7 +81,8 @@ export function AgentCreateScreen() { const { goBack, navigate } = useNavigation(); useExitOnCtrlC(); - const [currentField, setCurrentField] = React.useState("name"); + const [currentField, setCurrentField] = + React.useState("name"); const [formData, setFormData] = React.useState({ name: "", version: "", @@ -96,6 +100,7 @@ export function AgentCreateScreen() { ); const [success, setSuccess] = React.useState(false); const [createdAgentId, setCreatedAgentId] = React.useState(""); + const [showObjectPicker, setShowObjectPicker] = React.useState(false); const fields = getVisibleFields(formData.sourceType); const currentFieldIndex = fields.findIndex((f) => f.key === currentField); @@ -137,13 +142,8 @@ export function AgentCreateScreen() { setCurrentField("name"); return; } - if (!formData.version.trim()) { - setValidationError("Version is required"); - setCurrentField("version"); - return; - } - const st = formData.sourceType; + if ((st === "npm" || st === "pip") && !formData.packageName.trim()) { setValidationError("Package name is required"); setCurrentField("packageName"); @@ -197,7 +197,9 @@ export function AgentCreateScreen() { const agent = await createAgent({ name: formData.name, - version: formData.version, + ...(formData.version.trim() + ? { version: formData.version.trim() } + : {}), source, }); @@ -246,10 +248,44 @@ export function AgentCreateScreen() { handleSubmit(); return; } + + // Enter on objectId field opens object picker when empty + if ( + key.return && + currentField === "objectId" && + formData.sourceType === "object" && + !formData.objectId + ) { + setShowObjectPicker(true); + return; + } }, - { isActive: !submitting }, + { isActive: !submitting && !showObjectPicker }, ); + // Object picker for selecting object source + if (showObjectPicker) { + return ( + { + if (objects.length > 0) { + setFormData((prev) => ({ ...prev, objectId: objects[0].id })); + } + setShowObjectPicker(false); + }} + onCancel={() => setShowObjectPicker(false)} + initialSelected={formData.objectId ? [formData.objectId] : []} + /> + ); + } + // Submitting spinner if (submitting) { return ( @@ -283,7 +319,7 @@ export function AgentCreateScreen() { } // Determine which field has a validation error - const fieldError = (key: FormField): string | undefined => { + const fieldError = (key: AgentFormField): string | undefined => { if (!validationError) return undefined; if (currentField === key) return validationError; return undefined; @@ -317,14 +353,6 @@ export function AgentCreateScreen() { placeholder="Enter agent name..." error={fieldError("name")} /> - setFormData({ ...formData, version: v })} - isActive={currentField === "version"} - placeholder="e.g. 1.0.0 or a 40-char SHA" - error={fieldError("version")} - /> + setFormData({ ...formData, version: v })} + isActive={currentField === "version"} + placeholder="(optional) e.g. 1.0.0" + error={fieldError("version")} + /> + setFormData({ ...formData, version: v })} + isActive={currentField === "version"} + placeholder="(optional) e.g. branch or tag" + error={fieldError("version")} + /> setFormData({ ...formData, objectId: v })} isActive={currentField === "objectId"} - placeholder="Enter object ID..." + placeholder="Enter object ID or press Enter to pick..." error={fieldError("objectId")} /> )} diff --git a/src/screens/AgentDetailScreen.tsx b/src/screens/AgentDetailScreen.tsx index 74a9b25..a8a98b9 100644 --- a/src/screens/AgentDetailScreen.tsx +++ b/src/screens/AgentDetailScreen.tsx @@ -28,7 +28,7 @@ interface AgentDetailScreenProps { } export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { - const { goBack } = useNavigation(); + const { goBack, navigate } = useNavigation(); const { data: agent, @@ -107,7 +107,12 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { const detailSections: DetailSection[] = []; const basicFields = []; - basicFields.push({ label: "Version", value: agent.version }); + if (source?.type !== "object") { + const versionDisplay = agent.version || source?.git?.ref; + if (versionDisplay) { + basicFields.push({ label: "Version", value: versionDisplay }); + } + } if (agent.create_time_ms) { basicFields.push({ label: "Created", @@ -190,21 +195,33 @@ export function AgentDetailScreen({ agentId }: AgentDetailScreenProps) { } } + // "n" is safe here — detail screens don't use n/p pagination keys const isPublic = agent.is_public; - const operations: ResourceOperation[] = isPublic - ? [] - : [ - { - key: "delete", - label: "Delete Agent", - color: colors.error, - icon: figures.cross, - shortcut: "d", - }, - ]; + const operations: ResourceOperation[] = [ + { + key: "create-devbox", + label: "Create Devbox with Agent", + color: colors.success, + icon: figures.play, + shortcut: "n", + }, + ...(isPublic + ? [] + : [ + { + key: "delete", + label: "Delete Agent", + color: colors.error, + icon: figures.cross, + shortcut: "d", + }, + ]), + ]; const handleOperation = (operation: string) => { - if (operation === "delete") { + if (operation === "create-devbox") { + navigate("devbox-create", { agentId: agent.id }); + } else if (operation === "delete") { setShowDeleteConfirm(true); } }; diff --git a/src/screens/BenchmarkJobListScreen.tsx b/src/screens/BenchmarkJobListScreen.tsx index 33c9096..82ad69f 100644 --- a/src/screens/BenchmarkJobListScreen.tsx +++ b/src/screens/BenchmarkJobListScreen.tsx @@ -450,7 +450,7 @@ export function BenchmarkJobListScreen() { {showPopup && selectedJob && ( ({ key: op.key, label: op.label, diff --git a/src/screens/BenchmarkListScreen.tsx b/src/screens/BenchmarkListScreen.tsx index f99812e..bd25cb6 100644 --- a/src/screens/BenchmarkListScreen.tsx +++ b/src/screens/BenchmarkListScreen.tsx @@ -409,7 +409,7 @@ export function BenchmarkListScreen() { {showPopup && selectedBenchmark && ( ({ key: op.key, label: op.label, diff --git a/src/screens/BenchmarkRunListScreen.tsx b/src/screens/BenchmarkRunListScreen.tsx index 39199a9..df81086 100644 --- a/src/screens/BenchmarkRunListScreen.tsx +++ b/src/screens/BenchmarkRunListScreen.tsx @@ -395,7 +395,7 @@ export function BenchmarkRunListScreen() { {showPopup && selectedRun && ( ({ key: op.key, label: op.label, diff --git a/src/screens/DevboxCreateScreen.tsx b/src/screens/DevboxCreateScreen.tsx index 4ea1d0c..9766131 100644 --- a/src/screens/DevboxCreateScreen.tsx +++ b/src/screens/DevboxCreateScreen.tsx @@ -21,6 +21,7 @@ export function DevboxCreateScreen() { onCreate={handleCreate} initialBlueprintId={params.blueprintId} initialSnapshotId={params.snapshotId} + initialAgentId={params.agentId} /> ); } diff --git a/src/screens/ScenarioRunListScreen.tsx b/src/screens/ScenarioRunListScreen.tsx index 9c1b03a..8935c52 100644 --- a/src/screens/ScenarioRunListScreen.tsx +++ b/src/screens/ScenarioRunListScreen.tsx @@ -394,7 +394,7 @@ export function ScenarioRunListScreen({ {showPopup && selectedRun && ( ({ key: op.key, label: op.label, diff --git a/src/services/agentService.ts b/src/services/agentService.ts index 64bde02..79f8d49 100644 --- a/src/services/agentService.ts +++ b/src/services/agentService.ts @@ -25,9 +25,13 @@ export interface AgentColumn { */ function agentVersionText(agent: Agent): string { const src = (agent as any).source; + if (src?.type === "object") return "-"; + const pkg: string | undefined = src?.npm?.package_name || src?.pip?.package_name; - const version = agent.version || ""; + const version = agent.version || src?.git?.ref || ""; + + if (!version && !pkg) return "-"; // Strip leading @ and any scope prefix for comparison (e.g. "@scope/pkg" -> "pkg") const barePkg = pkg?.replace(/^@[^/]+\//, "") ?? ""; @@ -39,7 +43,7 @@ function agentVersionText(agent: Agent): string { if (showPkg) { return pkg!; } - return version; + return version || "-"; } // Fixed column widths (content + padding). These values never change. @@ -253,7 +257,7 @@ export async function listPublicAgents( export interface CreateAgentOptions { name: string; - version: string; + version?: string; source?: { type: string; npm?: { @@ -276,7 +280,11 @@ export interface CreateAgentOptions { */ export async function createAgent(options: CreateAgentOptions): Promise { const client = getClient(); - return client.agents.create(options); + const { version, ...rest } = options; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const params: any = { ...rest }; + if (version) params.version = version; + return client.agents.create(params); } /** diff --git a/src/utils/commands.ts b/src/utils/commands.ts index f520205..70f920d 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)", @@ -1193,10 +1197,7 @@ export function createProgram(): Command { .command("create") .description("Create a new agent") .requiredOption("--name ", "Agent name") - .requiredOption( - "--agent-version ", - "Version string (semver or SHA)", - ) + .option("--agent-version ", "Version string (optional)") .requiredOption("--source ", "Source type: npm|pip|git|object") .option("--package ", "Package name (for npm/pip sources)") .option("--registry-url ", "Registry URL (for npm/pip sources)") diff --git a/src/utils/mount.ts b/src/utils/mount.ts new file mode 100644 index 0000000..75e44e4 --- /dev/null +++ b/src/utils/mount.ts @@ -0,0 +1,68 @@ +/** + * 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 suffix = obj.id.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..66bb51a --- /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 for fallback", () => { + expect( + getDefaultObjectMountPath({ + id: "obj_short", + }), + ).toBe(`${DEFAULT_MOUNT_PATH}/object_bj_short`); + }); +});