diff --git a/packages/api/src/agent-workspace-info.ts b/packages/api/src/agent-workspace-info.ts index f6426a3a0..63806091c 100644 --- a/packages/api/src/agent-workspace-info.ts +++ b/packages/api/src/agent-workspace-info.ts @@ -37,3 +37,14 @@ export type AgentWorkspaceId = cliComponents['schemas']['WorkspaceId']; * Matches the contract in @kortex-hub/kortex-workspace-configuration. */ export type AgentWorkspaceConfiguration = configComponents['schemas']['WorkspaceConfiguration']; + +/** + * Options for creating (initializing) a new workspace via `kortex-cli init`. + */ +export interface AgentWorkspaceCreateOptions { + sourcePath: string; + agent: string; + runtime?: string; + name?: string; + project?: string; +} diff --git a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts index 4073688f5..fe7ff2ba6 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.spec.ts @@ -23,10 +23,12 @@ import { beforeEach, describe, expect, test, vi } from 'vitest'; import { parse as parseYAML } from 'yaml'; import type { IPCHandle } from '/@/plugin/api.js'; +import type { CliToolRegistry } from '/@/plugin/cli-tool-registry.js'; import type { Proxy } from '/@/plugin/proxy.js'; import { Exec } from '/@/plugin/util/exec.js'; -import type { AgentWorkspaceSummary } from '/@api/agent-workspace-info.js'; +import type { AgentWorkspaceCreateOptions, AgentWorkspaceSummary } from '/@api/agent-workspace-info.js'; import type { ApiSenderType } from '/@api/api-sender/api-sender-type.js'; +import type { CliToolInfo } from '/@api/cli-tool-info.js'; import { AgentWorkspaceManager } from './agent-workspace-manager.js'; @@ -59,18 +61,34 @@ const proxy = { isEnabled: vi.fn().mockReturnValue(false), } as unknown as Proxy; const exec = new Exec(proxy); +const cliToolRegistry = { + getCliToolInfos: vi + .fn() + .mockReturnValue([ + { name: 'kortex', path: '/home/user/.config/kortex-extensions/kortex-cli/kortex-cli-package/kortex-cli' }, + ]), +} as unknown as CliToolRegistry; + +const KORTEX_CLI_PATH = '/home/user/.config/kortex-extensions/kortex-cli/kortex-cli-package/kortex-cli'; function mockExecResult(stdout: string): RunResult { - return { command: 'kortex-cli', stdout, stderr: '' }; + return { command: KORTEX_CLI_PATH, stdout, stderr: '' }; } beforeEach(() => { vi.resetAllMocks(); - manager = new AgentWorkspaceManager(apiSender, ipcHandle, exec); + vi.mocked(cliToolRegistry.getCliToolInfos).mockReturnValue([ + { name: 'kortex', path: KORTEX_CLI_PATH }, + ] as unknown as CliToolInfo[]); + manager = new AgentWorkspaceManager(apiSender, ipcHandle, exec, cliToolRegistry); manager.init(); }); describe('init', () => { + test('registers IPC handler for create', () => { + expect(ipcHandle).toHaveBeenCalledWith('agent-workspace:create', expect.any(Function)); + }); + test('registers IPC handler for list', () => { expect(ipcHandle).toHaveBeenCalledWith('agent-workspace:list', expect.any(Function)); }); @@ -92,13 +110,90 @@ describe('init', () => { }); }); +describe('getCliPath', () => { + test('falls back to kortex-cli when no CLI tool is registered', async () => { + vi.mocked(cliToolRegistry.getCliToolInfos).mockReturnValue([]); + vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ items: [] }))); + + await manager.list(); + + expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'list', '--output', 'json'], undefined); + }); +}); + +describe('create', () => { + const defaultOptions: AgentWorkspaceCreateOptions = { + sourcePath: '/tmp/my-project', + agent: 'claude', + runtime: 'podman', + }; + + test('executes kortex-cli init with required flags and returns the workspace id', async () => { + vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' }))); + + const result = await manager.create(defaultOptions); + + expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, [ + 'init', + '/tmp/my-project', + '--runtime', + 'podman', + '--agent', + 'claude', + '--output', + 'json', + ]); + expect(result).toEqual({ id: 'ws-new' }); + }); + + test('defaults runtime to podman when not specified', async () => { + vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' }))); + + await manager.create({ sourcePath: '/tmp/my-project', agent: 'claude' }); + + expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, expect.arrayContaining(['--runtime', 'podman'])); + }); + + test('includes optional name flag when provided', async () => { + vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' }))); + + await manager.create({ ...defaultOptions, name: 'my-workspace' }); + + expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, expect.arrayContaining(['--name', 'my-workspace'])); + }); + + test('includes optional project flag when provided', async () => { + vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' }))); + + await manager.create({ ...defaultOptions, project: 'my-project' }); + + expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, expect.arrayContaining(['--project', 'my-project'])); + }); + + test('emits agent-workspace-update event', async () => { + vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ id: 'ws-new' }))); + + await manager.create(defaultOptions); + + expect(apiSender.send).toHaveBeenCalledWith('agent-workspace-update'); + }); + + test('rejects when source directory does not exist', async () => { + vi.spyOn(exec, 'exec').mockRejectedValue(new Error('sources directory does not exist: /tmp/not-found')); + + await expect(manager.create({ ...defaultOptions, sourcePath: '/tmp/not-found' })).rejects.toThrow( + 'sources directory does not exist: /tmp/not-found', + ); + }); +}); + describe('list', () => { test('executes kortex-cli workspace list and returns items', async () => { vi.spyOn(exec, 'exec').mockResolvedValue(mockExecResult(JSON.stringify({ items: TEST_SUMMARIES }))); const result = await manager.list(); - expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'list', '--output', 'json']); + expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, ['workspace', 'list', '--output', 'json'], undefined); expect(result).toHaveLength(2); expect(result.map(s => s.id)).toEqual(['ws-1', 'ws-2']); }); @@ -129,7 +224,11 @@ describe('remove', () => { const result = await manager.remove('ws-1'); - expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'remove', 'ws-1', '--output', 'json']); + expect(exec.exec).toHaveBeenCalledWith( + KORTEX_CLI_PATH, + ['workspace', 'remove', 'ws-1', '--output', 'json'], + undefined, + ); expect(result).toEqual({ id: 'ws-1' }); }); @@ -156,7 +255,7 @@ describe('getConfiguration', () => { const result = await manager.getConfiguration('ws-1'); - expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'list', '--output', 'json']); + expect(exec.exec).toHaveBeenCalledWith(KORTEX_CLI_PATH, ['workspace', 'list', '--output', 'json'], undefined); expect(readFile).toHaveBeenCalledWith('/tmp/ws1/.kortex.yaml', 'utf-8'); expect(parseYAML).toHaveBeenCalledWith('name: test-workspace-1\n'); expect(result).toEqual({ name: 'test-workspace-1' }); @@ -184,7 +283,11 @@ describe('start', () => { const result = await manager.start('ws-1'); - expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'start', 'ws-1', '--output', 'json']); + expect(exec.exec).toHaveBeenCalledWith( + KORTEX_CLI_PATH, + ['workspace', 'start', 'ws-1', '--output', 'json'], + undefined, + ); expect(result).toEqual({ id: 'ws-1' }); }); @@ -209,7 +312,11 @@ describe('stop', () => { const result = await manager.stop('ws-1'); - expect(exec.exec).toHaveBeenCalledWith('kortex-cli', ['workspace', 'stop', 'ws-1', '--output', 'json']); + expect(exec.exec).toHaveBeenCalledWith( + KORTEX_CLI_PATH, + ['workspace', 'stop', 'ws-1', '--output', 'json'], + undefined, + ); expect(result).toEqual({ id: 'ws-1' }); }); diff --git a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts index 0a02cf270..a2c4b5762 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts @@ -23,9 +23,11 @@ import { inject, injectable, preDestroy } from 'inversify'; import { parse as parseYAML } from 'yaml'; import { IPCHandle } from '/@/plugin/api.js'; +import { CliToolRegistry } from '/@/plugin/cli-tool-registry.js'; import { Exec } from '/@/plugin/util/exec.js'; import type { AgentWorkspaceConfiguration, + AgentWorkspaceCreateOptions, AgentWorkspaceId, AgentWorkspaceSummary, } from '/@api/agent-workspace-info.js'; @@ -43,11 +45,53 @@ export class AgentWorkspaceManager implements Disposable { private readonly ipcHandle: IPCHandle, @inject(Exec) private readonly exec: Exec, + @inject(CliToolRegistry) + private readonly cliToolRegistry: CliToolRegistry, ) {} - private async execKortex(args: string[]): Promise { - const result = await this.exec.exec('kortex-cli', ['workspace', ...args, '--output', 'json']); - return JSON.parse(result.stdout) as T; + private getCliPath(): string { + const tool = this.cliToolRegistry.getCliToolInfos().find(t => t.name === 'kortex'); + if (tool?.path) { + return tool.path; + } + return 'kortex-cli'; + } + + private async execKortex(args: string[], options?: { cwd?: string }): Promise { + const cliPath = this.getCliPath(); + const fullArgs = ['workspace', ...args, '--output', 'json']; + console.log(`Executing: ${cliPath} ${fullArgs.join(' ')}`); + try { + const result = await this.exec.exec(cliPath, fullArgs, options); + return JSON.parse(result.stdout) as T; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.log(`kortex-cli failed: ${cliPath} ${fullArgs.join(' ')} — ${message}`); + throw err; + } + } + + async create(options: AgentWorkspaceCreateOptions): Promise { + const cliPath = this.getCliPath(); + const runtime = options.runtime ?? 'podman'; + const args = ['init', options.sourcePath, '--runtime', runtime, '--agent', options.agent, '--output', 'json']; + if (options.name) { + args.push('--name', options.name); + } + if (options.project) { + args.push('--project', options.project); + } + console.log(`Executing: ${cliPath} ${args.join(' ')}`); + try { + const result = await this.exec.exec(cliPath, args); + const workspaceId = JSON.parse(result.stdout) as AgentWorkspaceId; + this.apiSender.send('agent-workspace-update'); + return workspaceId; + } catch (err: unknown) { + const message = err instanceof Error ? err.message : String(err); + console.log(`kortex-cli failed: ${cliPath} ${args.join(' ')} — ${message}`); + throw err; + } } async list(): Promise { @@ -84,6 +128,13 @@ export class AgentWorkspaceManager implements Disposable { } init(): void { + this.ipcHandle( + 'agent-workspace:create', + async (_listener: unknown, options: AgentWorkspaceCreateOptions): Promise => { + return this.create(options); + }, + ); + this.ipcHandle('agent-workspace:list', async (): Promise => { return this.list(); }); diff --git a/packages/main/src/plugin/index.ts b/packages/main/src/plugin/index.ts index d433b1825..8cf36f8e7 100644 --- a/packages/main/src/plugin/index.ts +++ b/packages/main/src/plugin/index.ts @@ -577,6 +577,7 @@ export class PluginSystem { container.bind(MCPExchanges).toSelf().inSingletonScope(); container.bind(ProviderRegistry).toSelf().inSingletonScope(); container.bind(MCPManager).toSelf().inSingletonScope(); + container.bind(CliToolRegistry).toSelf().inSingletonScope(); container.bind(AgentWorkspaceManager).toSelf().inSingletonScope(); container.bind(FlowManager).toSelf().inSingletonScope(); container.bind(SkillManager).toSelf().inSingletonScope(); @@ -767,7 +768,6 @@ export class PluginSystem { libpodApiInit.init(); container.bind(AuthenticationImpl).toSelf().inSingletonScope(); - container.bind(CliToolRegistry).toSelf().inSingletonScope(); container.bind(ImageCheckerImpl).toSelf().inSingletonScope(); container.bind(ImageFilesRegistry).toSelf().inSingletonScope(); container.bind(Troubleshooting).toSelf().inSingletonScope(); diff --git a/packages/preload/src/index.ts b/packages/preload/src/index.ts index cd744b21f..7efd61fbb 100644 --- a/packages/preload/src/index.ts +++ b/packages/preload/src/index.ts @@ -44,7 +44,12 @@ import type { import type { DynamicToolUIPart, UIMessageChunk } from 'ai'; import { contextBridge, ipcRenderer } from 'electron'; -import type { AgentWorkspaceConfiguration, AgentWorkspaceId, AgentWorkspaceSummary } from '/@api/agent-workspace-info'; +import type { + AgentWorkspaceConfiguration, + AgentWorkspaceCreateOptions, + AgentWorkspaceId, + AgentWorkspaceSummary, +} from '/@api/agent-workspace-info'; import type { ApiSenderType } from '/@api/api-sender/api-sender-type'; import type { AuthenticationProviderInfo } from '/@api/authentication/authentication'; import type { DetectFlowFieldsParams, DetectFlowFieldsResult } from '/@api/chat/detect-flow-fields-schema.ts'; @@ -314,6 +319,13 @@ export function initExposure(): void { }); // Agent Workspaces + contextBridge.exposeInMainWorld( + 'createAgentWorkspace', + async (options: AgentWorkspaceCreateOptions): Promise => { + return ipcInvoke('agent-workspace:create', options); + }, + ); + contextBridge.exposeInMainWorld('listAgentWorkspaces', async (): Promise => { return ipcInvoke('agent-workspace:list'); }); diff --git a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte index aaef7d99e..d965ff5a7 100644 --- a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte +++ b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte @@ -112,8 +112,11 @@ function cancel(): void { handleNavigation({ page: NavigationPage.AGENT_WORKSPACES }); } -function startWorkspace(): void { - if (!sessionName.trim()) return; +let creating = $state(false); +let error = $state(''); + +async function startWorkspace(): Promise { + if (!sessionName.trim() || !workingDir.trim() || !selectedAgent) return; const config = { name: sessionName, @@ -126,6 +129,21 @@ function startWorkspace(): void { mcpServers: selectedMcpIds, }; console.log('Starting workspace with config:', config); + + creating = true; + error = ''; + try { + await window.createAgentWorkspace({ + sourcePath: workingDir, + agent: selectedAgent, + name: sessionName, + }); + handleNavigation({ page: NavigationPage.AGENT_WORKSPACES }); + } catch (err: unknown) { + error = err instanceof Error ? err.message : String(err); + } finally { + creating = false; + } } @@ -264,6 +282,10 @@ function startWorkspace(): void { {/if} + {#if error} +
{error}
+ {/if} +
@@ -272,7 +294,9 @@ function startWorkspace(): void {
- +