From 9c91011b5b58272b8792a7681571e5a2b99bfaef Mon Sep 17 00:00:00 2001 From: Brian Date: Thu, 2 Apr 2026 09:52:12 -0400 Subject: [PATCH 1/2] feat: add workspace create flow with kortex-cli integration Wire up create IPC handler, resolve CLI path from CliToolRegistry, and connect the renderer form to the backend. Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Brian --- packages/api/src/agent-workspace-info.ts | 11 ++ .../agent-workspace-manager.spec.ts | 123 ++++++++++++++++-- .../agent-workspace-manager.ts | 40 +++++- packages/main/src/plugin/index.ts | 2 +- packages/preload/src/index.ts | 14 +- .../AgentWorkspaceCreate.svelte | 40 ++++-- 6 files changed, 204 insertions(+), 26 deletions(-) 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..409b408d0 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,13 +45,40 @@ 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']); + 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 result = await this.exec.exec(cliPath, ['workspace', ...args, '--output', 'json'], options); return JSON.parse(result.stdout) as T; } + 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); + } + const result = await this.exec.exec(cliPath, args); + const workspaceId = JSON.parse(result.stdout) as AgentWorkspaceId; + this.apiSender.send('agent-workspace-update'); + return workspaceId; + } + async list(): Promise { const response = await this.execKortex<{ items: AgentWorkspaceSummary[] }>(['list']); return response.items; @@ -84,6 +113,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..277c5891f 100644 --- a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte +++ b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte @@ -112,20 +112,26 @@ function cancel(): void { handleNavigation({ page: NavigationPage.AGENT_WORKSPACES }); } -function startWorkspace(): void { - if (!sessionName.trim()) return; +let creating = $state(false); +let error = $state(''); - const config = { - name: sessionName, - workingDir, - description, - agent: selectedAgent, - fileAccess: selectedFileAccess, - customPaths: selectedFileAccess === 'custom' ? customPaths.filter(p => p.trim()) : undefined, - skills: selectedSkillIds, - mcpServers: selectedMcpIds, - }; - console.log('Starting workspace with config:', config); +async function startWorkspace(): Promise { + if (!sessionName.trim() || !workingDir.trim() || !selectedAgent) return; + + 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 +270,10 @@ function startWorkspace(): void { {/if} + {#if error} +
{error}
+ {/if} +
@@ -272,7 +282,9 @@ function startWorkspace(): void {
- +
From 24ce8b83ea4ca9f6bd0e1de7e66fe93dc342c1fc Mon Sep 17 00:00:00 2001 From: Brian Date: Fri, 3 Apr 2026 01:57:50 -0400 Subject: [PATCH 2/2] feat: add kortex-cli logging for all workspace operations Co-Authored-By: Claude Opus 4.6 (1M context) Signed-off-by: Brian --- .../agent-workspace-manager.ts | 27 ++++++++++++++----- .../AgentWorkspaceCreate.svelte | 12 +++++++++ 2 files changed, 33 insertions(+), 6 deletions(-) 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 409b408d0..a2c4b5762 100644 --- a/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts +++ b/packages/main/src/plugin/agent-workspace/agent-workspace-manager.ts @@ -59,8 +59,16 @@ export class AgentWorkspaceManager implements Disposable { private async execKortex(args: string[], options?: { cwd?: string }): Promise { const cliPath = this.getCliPath(); - const result = await this.exec.exec(cliPath, ['workspace', ...args, '--output', 'json'], options); - return JSON.parse(result.stdout) as T; + 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 { @@ -73,10 +81,17 @@ export class AgentWorkspaceManager implements Disposable { if (options.project) { args.push('--project', options.project); } - const result = await this.exec.exec(cliPath, args); - const workspaceId = JSON.parse(result.stdout) as AgentWorkspaceId; - this.apiSender.send('agent-workspace-update'); - return workspaceId; + 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 { diff --git a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte index 277c5891f..d965ff5a7 100644 --- a/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte +++ b/packages/renderer/src/lib/agent-workspaces/AgentWorkspaceCreate.svelte @@ -118,6 +118,18 @@ let error = $state(''); async function startWorkspace(): Promise { if (!sessionName.trim() || !workingDir.trim() || !selectedAgent) return; + const config = { + name: sessionName, + workingDir, + description, + agent: selectedAgent, + fileAccess: selectedFileAccess, + customPaths: selectedFileAccess === 'custom' ? customPaths.filter(p => p.trim()) : undefined, + skills: selectedSkillIds, + mcpServers: selectedMcpIds, + }; + console.log('Starting workspace with config:', config); + creating = true; error = ''; try {