@inkeep/create-agents 0.0.0-chat-to-edit-20251119071712

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,217 @@
1
+ import os from 'node:os';
2
+ import path, { dirname } from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { execa } from 'execa';
5
+ import fs from 'fs-extra';
6
+ const __dirname = dirname(fileURLToPath(import.meta.url));
7
+ /**
8
+ * Run the create-agents CLI with the given arguments
9
+ */
10
+ export async function runCreateAgentsCLI(args, cwd, timeout = 300000 // 5 minutes default for full flow
11
+ ) {
12
+ const cliPath = path.join(__dirname, '../../../src/index.ts');
13
+ try {
14
+ // Run using tsx to execute TypeScript directly
15
+ const result = await execa('tsx', [cliPath, ...args], {
16
+ cwd,
17
+ timeout,
18
+ env: { ...process.env, FORCE_COLOR: '0' }, // Disable colors for easier assertion
19
+ all: true, // Capture combined stdout + stderr in order
20
+ });
21
+ return {
22
+ stdout: result.stdout,
23
+ stderr: result.stderr,
24
+ exitCode: result.exitCode,
25
+ };
26
+ }
27
+ catch (error) {
28
+ // execa throws on non-zero exit codes, capture the error info
29
+ return {
30
+ stdout: error.stdout || '',
31
+ stderr: error.stderr || '',
32
+ exitCode: error.exitCode || 1,
33
+ };
34
+ }
35
+ }
36
+ /**
37
+ * Run a command in the created project directory
38
+ */
39
+ export async function runCommand(command, args, cwd, timeout = 120000 // 2 minutes default
40
+ ) {
41
+ try {
42
+ const result = await execa(command, args, {
43
+ cwd,
44
+ timeout,
45
+ env: { ...process.env, FORCE_COLOR: '0' },
46
+ shell: true,
47
+ });
48
+ return {
49
+ stdout: result.stdout,
50
+ stderr: result.stderr,
51
+ exitCode: result.exitCode,
52
+ };
53
+ }
54
+ catch (error) {
55
+ return {
56
+ stdout: error.stdout || '',
57
+ stderr: error.stderr || '',
58
+ exitCode: error.exitCode || 1,
59
+ };
60
+ }
61
+ }
62
+ /**
63
+ * Create a temporary directory for testing
64
+ */
65
+ export async function createTempDir(prefix = 'create-agents-e2e-') {
66
+ return fs.mkdtemp(path.join(os.tmpdir(), prefix));
67
+ }
68
+ /**
69
+ * Clean up a test directory with retries
70
+ */
71
+ export async function cleanupDir(dir) {
72
+ if (!(await fs.pathExists(dir))) {
73
+ return;
74
+ }
75
+ try {
76
+ // Try multiple times with delays (common in CI)
77
+ for (let i = 0; i < 3; i++) {
78
+ try {
79
+ await fs.remove(dir);
80
+ return;
81
+ }
82
+ catch (error) {
83
+ if (i === 2)
84
+ throw error; // Last attempt, throw the error
85
+ await new Promise((resolve) => setTimeout(resolve, 1000)); // Wait 1s before retry
86
+ }
87
+ }
88
+ }
89
+ catch (error) {
90
+ // If still failing, try force removal
91
+ if (error.code === 'ENOTEMPTY' || error.code === 'EBUSY') {
92
+ await execa('rm', ['-rf', dir], { shell: true }).catch(() => {
93
+ console.warn(`Failed to clean up ${dir}`);
94
+ });
95
+ }
96
+ }
97
+ }
98
+ /**
99
+ * Verify that a file exists and optionally check its contents
100
+ */
101
+ export async function verifyFile(filePath, expectedContents) {
102
+ const exists = await fs.pathExists(filePath);
103
+ if (!exists) {
104
+ throw new Error(`Expected file to exist: ${filePath}`);
105
+ }
106
+ if (expectedContents) {
107
+ const content = await fs.readFile(filePath, 'utf-8');
108
+ for (const expected of expectedContents) {
109
+ if (typeof expected === 'string') {
110
+ if (!content.includes(expected)) {
111
+ throw new Error(`Expected file ${filePath} to contain: ${expected}`);
112
+ }
113
+ }
114
+ else {
115
+ if (!expected.test(content)) {
116
+ throw new Error(`Expected file ${filePath} to match pattern: ${expected}`);
117
+ }
118
+ }
119
+ }
120
+ }
121
+ }
122
+ /**
123
+ * Verify that a directory has the expected structure
124
+ */
125
+ export async function verifyDirectoryStructure(baseDir, expectedPaths) {
126
+ for (const expectedPath of expectedPaths) {
127
+ const fullPath = path.join(baseDir, expectedPath);
128
+ const exists = await fs.pathExists(fullPath);
129
+ if (!exists) {
130
+ throw new Error(`Expected path to exist: ${fullPath}`);
131
+ }
132
+ }
133
+ }
134
+ /**
135
+ * Link local monorepo packages to the created project
136
+ * This replaces published @inkeep packages with local versions for testing
137
+ */
138
+ export async function linkLocalPackages(projectDir, monorepoRoot) {
139
+ const packageJsonPaths = [
140
+ path.join(projectDir, 'package.json'),
141
+ path.join(projectDir, 'apps/manage-api/package.json'),
142
+ path.join(projectDir, 'apps/run-api/package.json'),
143
+ ];
144
+ const packageJsons = {};
145
+ for (const packageJsonPath of packageJsonPaths) {
146
+ packageJsons[packageJsonPath] = await fs.readJson(packageJsonPath);
147
+ }
148
+ // Define local @inkeep packages to link
149
+ const inkeepPackages = {
150
+ '@inkeep/agents-sdk': `link:${path.join(monorepoRoot, 'packages/agents-sdk')}`,
151
+ '@inkeep/agents-core': `link:${path.join(monorepoRoot, 'packages/agents-core')}`,
152
+ '@inkeep/agents-manage-api': `link:${path.join(monorepoRoot, 'agents-manage-api')}`,
153
+ '@inkeep/agents-run-api': `link:${path.join(monorepoRoot, 'agents-run-api')}`,
154
+ '@inkeep/agents-cli': `link:${path.join(monorepoRoot, 'agents-cli')}`,
155
+ };
156
+ // Replace package versions with local links
157
+ for (const [pkg, linkPath] of Object.entries(inkeepPackages)) {
158
+ for (const packageJsonPath of packageJsonPaths) {
159
+ if (packageJsons[packageJsonPath].dependencies?.[pkg]) {
160
+ packageJsons[packageJsonPath].dependencies[pkg] = linkPath;
161
+ }
162
+ if (packageJsons[packageJsonPath].devDependencies?.[pkg]) {
163
+ packageJsons[packageJsonPath].devDependencies[pkg] = linkPath;
164
+ }
165
+ }
166
+ }
167
+ // Write updated package.json
168
+ for (const packageJsonPath of packageJsonPaths) {
169
+ await fs.writeJson(packageJsonPath, packageJsons[packageJsonPath], { spaces: 2 });
170
+ }
171
+ // Reinstall to create the symlinks
172
+ await execa('pnpm', ['install', '--no-frozen-lockfile'], {
173
+ cwd: projectDir,
174
+ env: { ...process.env, FORCE_COLOR: '0' },
175
+ });
176
+ }
177
+ /**
178
+ * Wait for a server to be ready by polling a health endpoint
179
+ */
180
+ export async function waitForServerReady(url, timeout) {
181
+ const start = Date.now();
182
+ let lastError = null;
183
+ let attempts = 0;
184
+ console.log(`Waiting for server at ${url}...`);
185
+ while (Date.now() - start < timeout) {
186
+ attempts++;
187
+ try {
188
+ const response = await fetch(url, {
189
+ signal: AbortSignal.timeout(5000), // 5 second timeout per request
190
+ });
191
+ if (response.ok) {
192
+ console.log(`✓ Server ready at ${url} after ${attempts} attempts (${Date.now() - start}ms)`);
193
+ return;
194
+ }
195
+ lastError = new Error(`HTTP ${response.status}: ${response.statusText}`);
196
+ // Log status every 10 attempts in CI
197
+ if (process.env.CI && attempts % 10 === 0) {
198
+ console.log(`Still waiting for ${url}... (attempt ${attempts}, ${Math.floor((Date.now() - start) / 1000)}s elapsed)`);
199
+ }
200
+ }
201
+ catch (error) {
202
+ // Server not ready yet or connection refused
203
+ lastError = error instanceof Error ? error : new Error(String(error));
204
+ // Log connection errors periodically in CI
205
+ if (process.env.CI && attempts % 15 === 0) {
206
+ console.log(`Connection attempt ${attempts} failed: ${lastError.message}`);
207
+ }
208
+ }
209
+ // Wait before next attempt (exponential backoff up to 5s)
210
+ const waitTime = Math.min(1000 + attempts * 100, 5000);
211
+ await new Promise((resolve) => setTimeout(resolve, waitTime));
212
+ }
213
+ // Timeout reached - provide detailed error
214
+ const elapsed = Date.now() - start;
215
+ const errorDetails = lastError ? `: ${lastError.message}` : '';
216
+ throw new Error(`Server not ready at ${url} after ${elapsed}ms (${attempts} attempts)${errorDetails}`);
217
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,283 @@
1
+ import path from 'node:path';
2
+ import fs from 'fs-extra';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+ import { cloneTemplate, replaceContentInFiles, replaceObjectProperties, } from '../templates';
5
+ // Mock dependencies
6
+ vi.mock('fs-extra');
7
+ vi.mock('degit', () => ({
8
+ default: vi.fn(() => ({
9
+ clone: vi.fn().mockResolvedValue(undefined),
10
+ })),
11
+ }));
12
+ describe('Template Content Replacement', () => {
13
+ beforeEach(() => {
14
+ vi.clearAllMocks();
15
+ // Setup default fs-extra mocks
16
+ vi.mocked(fs.pathExists).mockResolvedValue(true);
17
+ vi.mocked(fs.readFile).mockResolvedValue('');
18
+ vi.mocked(fs.writeFile).mockResolvedValue(undefined);
19
+ vi.mocked(fs.mkdir).mockResolvedValue(undefined);
20
+ });
21
+ describe('replaceObjectProperties', () => {
22
+ it('should replace a simple object property', async () => {
23
+ const content = `export const myProject = project({
24
+ id: 'test-project',
25
+ models: {
26
+ base: {
27
+ model: 'gpt-4o-mini',
28
+ },
29
+ },
30
+ });`;
31
+ const replacement = {
32
+ base: {
33
+ model: 'anthropic/claude-3-5-haiku-20241022',
34
+ },
35
+ summarizer: {
36
+ model: 'anthropic/claude-3-5-haiku-20241022',
37
+ },
38
+ };
39
+ const result = await replaceObjectProperties(content, { models: replacement });
40
+ expect(result).toContain("'model': 'anthropic/claude-3-5-haiku-20241022'");
41
+ expect(result).toContain("'summarizer'");
42
+ expect(result).not.toContain('gpt-4o-mini');
43
+ });
44
+ it('should handle nested objects correctly', async () => {
45
+ const content = `export const config = {
46
+ models: {
47
+ base: {
48
+ model: 'old-model',
49
+ temperature: 0.5,
50
+ },
51
+ structured: {
52
+ model: 'old-structured',
53
+ }
54
+ },
55
+ other: 'value'
56
+ };`;
57
+ const replacement = {
58
+ base: {
59
+ model: 'new-model',
60
+ temperature: 0.8,
61
+ },
62
+ structured: {
63
+ model: 'new-structured',
64
+ },
65
+ };
66
+ const result = await replaceObjectProperties(content, { models: replacement });
67
+ expect(result).toContain("'model': 'new-model'");
68
+ expect(result).toContain("'model': 'new-structured'");
69
+ expect(result).toContain("'temperature': 0.8");
70
+ expect(result).toContain("other: 'value'");
71
+ expect(result).not.toContain('old-model');
72
+ });
73
+ it('should preserve code structure and formatting', async () => {
74
+ const content = `const project = {
75
+ id: 'test',
76
+ models: {
77
+ base: { model: 'old' }
78
+ },
79
+ description: 'test project'
80
+ };`;
81
+ const replacement = { base: { model: 'new' } };
82
+ const result = await replaceObjectProperties(content, { models: replacement });
83
+ expect(result).toContain("id: 'test'");
84
+ expect(result).toContain("description: 'test project'");
85
+ expect(result).toContain("'model': 'new'");
86
+ });
87
+ it('should handle multiple property replacements', async () => {
88
+ const content = `export const config = {
89
+ models: { base: { model: 'old1' } },
90
+ tools: { search: { enabled: false } },
91
+ data: 'keep this'
92
+ };`;
93
+ const replacements = {
94
+ models: { base: { model: 'new1' } },
95
+ tools: { search: { enabled: true }, newTool: { type: 'api' } },
96
+ };
97
+ const result = await replaceObjectProperties(content, replacements);
98
+ expect(result).toContain("'model': 'new1'");
99
+ expect(result).toContain("'enabled': true");
100
+ expect(result).toContain("'newTool'");
101
+ expect(result).toContain("data: 'keep this'");
102
+ });
103
+ });
104
+ describe('replaceContentInFiles', () => {
105
+ it('should process multiple files with replacements', async () => {
106
+ const replacements = [
107
+ {
108
+ filePath: 'index.ts',
109
+ replacements: {
110
+ models: { base: { model: 'new-model' } },
111
+ },
112
+ },
113
+ {
114
+ filePath: 'config.ts',
115
+ replacements: {
116
+ settings: { debug: true },
117
+ },
118
+ },
119
+ ];
120
+ const indexContent = `export const project = { models: { base: { model: 'old' } } };`;
121
+ const configContent = `export const config = { settings: { debug: false } };`;
122
+ vi.mocked(fs.readFile)
123
+ .mockResolvedValueOnce(indexContent)
124
+ .mockResolvedValueOnce(configContent);
125
+ await replaceContentInFiles('/target/path', replacements);
126
+ expect(fs.readFile).toHaveBeenCalledTimes(2);
127
+ expect(fs.readFile).toHaveBeenCalledWith(path.join('/target/path', 'index.ts'), 'utf-8');
128
+ expect(fs.readFile).toHaveBeenCalledWith(path.join('/target/path', 'config.ts'), 'utf-8');
129
+ expect(fs.writeFile).toHaveBeenCalledTimes(2);
130
+ // Verify the content contains the replacements
131
+ const writeCalls = vi.mocked(fs.writeFile).mock.calls;
132
+ expect(writeCalls[0][1]).toContain("'model': 'new-model'");
133
+ expect(writeCalls[1][1]).toContain("'debug': true");
134
+ });
135
+ it('should warn and skip non-existent files', async () => {
136
+ const consoleSpy = vi.spyOn(console, 'warn').mockImplementation(() => { });
137
+ vi.mocked(fs.pathExists).mockResolvedValue(false);
138
+ const replacements = [
139
+ {
140
+ filePath: 'non-existent.ts',
141
+ replacements: { test: 'value' },
142
+ },
143
+ ];
144
+ await replaceContentInFiles('/target/path', replacements);
145
+ expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining('non-existent.ts'));
146
+ expect(fs.readFile).not.toHaveBeenCalled();
147
+ expect(fs.writeFile).not.toHaveBeenCalled();
148
+ consoleSpy.mockRestore();
149
+ });
150
+ });
151
+ describe('cloneTemplate with replacements', () => {
152
+ it('should clone template and apply replacements', async () => {
153
+ const degit = await import('degit');
154
+ const mockEmitter = { clone: vi.fn().mockResolvedValue(undefined) };
155
+ vi.mocked(degit.default).mockReturnValue(mockEmitter);
156
+ const replacements = [
157
+ {
158
+ filePath: 'index.ts',
159
+ replacements: {
160
+ models: { base: { model: 'new-model' } },
161
+ },
162
+ },
163
+ ];
164
+ const fileContent = `export const project = { models: { base: { model: 'old' } } };`;
165
+ vi.mocked(fs.readFile).mockResolvedValue(fileContent);
166
+ await cloneTemplate('https://github.com/test/repo', '/target/path', replacements);
167
+ expect(mockEmitter.clone).toHaveBeenCalledWith('/target/path');
168
+ expect(fs.readFile).toHaveBeenCalledWith(path.join('/target/path', 'index.ts'), 'utf-8');
169
+ expect(fs.writeFile).toHaveBeenCalledWith(path.join('/target/path', 'index.ts'), expect.stringContaining("'model': 'new-model'"), 'utf-8');
170
+ });
171
+ it('should clone template without replacements when none provided', async () => {
172
+ const degit = await import('degit');
173
+ const mockEmitter = { clone: vi.fn().mockResolvedValue(undefined) };
174
+ vi.mocked(degit.default).mockReturnValue(mockEmitter);
175
+ await cloneTemplate('https://github.com/test/repo', '/target/path');
176
+ expect(mockEmitter.clone).toHaveBeenCalledWith('/target/path');
177
+ expect(fs.readFile).not.toHaveBeenCalled();
178
+ expect(fs.writeFile).not.toHaveBeenCalled();
179
+ });
180
+ it('should handle clone errors gracefully', async () => {
181
+ const degit = await import('degit');
182
+ const mockEmitter = { clone: vi.fn().mockRejectedValue(new Error('Clone failed')) };
183
+ vi.mocked(degit.default).mockReturnValue(mockEmitter);
184
+ const processExitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
185
+ throw new Error('process.exit called');
186
+ });
187
+ await expect(cloneTemplate('https://github.com/test/repo', '/target/path')).rejects.toThrow('process.exit called');
188
+ expect(processExitSpy).toHaveBeenCalledWith(1);
189
+ processExitSpy.mockRestore();
190
+ });
191
+ });
192
+ describe('Integration tests', () => {
193
+ it('should handle real-world TypeScript project structure', async () => {
194
+ const projectContent = `import { project } from '@inkeep/agents-sdk';
195
+ import { weatherAgent } from './agent/weather-agent';
196
+
197
+ export const myProject = project({
198
+ id: 'weather-project',
199
+ name: 'Weather Project',
200
+ description: 'Weather project template',
201
+ agent: () => [weatherAgent],
202
+ models: {
203
+ base: {
204
+ model: 'gpt-4o-mini',
205
+ },
206
+ structuredOutput: {
207
+ model: 'gpt-4o-mini',
208
+ },
209
+ summarizer: {
210
+ model: 'gpt-4o-mini',
211
+ }
212
+ },
213
+ });`;
214
+ const newModels = {
215
+ base: {
216
+ model: 'anthropic/claude-3-5-haiku-20241022',
217
+ },
218
+ structuredOutput: {
219
+ model: 'anthropic/claude-3-5-haiku-20241022',
220
+ },
221
+ summarizer: {
222
+ model: 'anthropic/claude-3-5-haiku-20241022',
223
+ },
224
+ };
225
+ const result = await replaceObjectProperties(projectContent, { models: newModels });
226
+ // Should preserve imports and structure
227
+ expect(result).toContain("import { project } from '@inkeep/agents-sdk'");
228
+ expect(result).toContain("import { weatherAgent } from './agent/weather-agent'");
229
+ expect(result).toContain("id: 'weather-project'");
230
+ expect(result).toContain("name: 'Weather Project'");
231
+ expect(result).toContain('agent: () => [weatherAgent]');
232
+ // Should replace all model configurations
233
+ expect(result).toContain('anthropic/claude-3-5-haiku-20241022');
234
+ expect(result).not.toContain('gpt-4o-mini');
235
+ // Should maintain proper structure
236
+ expect(result).toContain("'base': {");
237
+ expect(result).toContain("'structuredOutput': {");
238
+ expect(result).toContain("'summarizer': {");
239
+ });
240
+ it('should handle edge cases in TypeScript syntax', async () => {
241
+ const content = `export const config = {
242
+ models: {
243
+ base: { model: "double-quotes" },
244
+ other: {
245
+ model: 'single-quotes',
246
+ nested: { value: true }
247
+ },
248
+ },
249
+ // Comment here
250
+ data: 'preserve me'
251
+ };`;
252
+ const replacement = {
253
+ base: { model: 'new-base' },
254
+ other: { model: 'new-other', nested: { value: false } },
255
+ };
256
+ const result = await replaceObjectProperties(content, { models: replacement });
257
+ expect(result).toContain("'model': 'new-base'");
258
+ expect(result).toContain("'model': 'new-other'");
259
+ expect(result).toContain("'value': false");
260
+ expect(result).toContain('// Comment here');
261
+ expect(result).toContain("data: 'preserve me'");
262
+ });
263
+ it('should inject models property when it does not exist', async () => {
264
+ const content = `export const myProject = project({
265
+ id: 'test-project',
266
+ name: 'Test Project',
267
+ description: 'A test project without models',
268
+ agent: () => [],
269
+ });`;
270
+ const replacement = {
271
+ base: { model: 'anthropic/claude-3-5-haiku-20241022' },
272
+ structuredOutput: { model: 'anthropic/claude-3-5-haiku-20241022' },
273
+ };
274
+ const result = await replaceObjectProperties(content, { models: replacement });
275
+ expect(result).toContain('models:');
276
+ expect(result).toContain("'base': {");
277
+ expect(result).toContain("'model': 'anthropic/claude-3-5-haiku-20241022'");
278
+ expect(result).toContain("'structuredOutput': {");
279
+ expect(result).toContain("id: 'test-project'");
280
+ expect(result).toContain('agent: () => []');
281
+ });
282
+ });
283
+ });
@@ -0,0 +1 @@
1
+ export {};