@inkeep/create-agents 0.8.0 → 0.8.1
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.
- package/dist/__tests__/templates.test.d.ts +1 -0
- package/dist/__tests__/templates.test.js +283 -0
- package/dist/__tests__/utils.test.js +50 -10
- package/dist/templates.d.ts +15 -1
- package/dist/templates.js +226 -1
- package/dist/utils.js +15 -7
- package/package.json +1 -1
|
@@ -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 { weatherGraph } from './graphs/weather-graph';
|
|
196
|
+
|
|
197
|
+
export const myProject = project({
|
|
198
|
+
id: 'weather-project',
|
|
199
|
+
name: 'Weather Project',
|
|
200
|
+
description: 'Weather project template',
|
|
201
|
+
graphs: () => [weatherGraph],
|
|
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 { weatherGraph } from './graphs/weather-graph'");
|
|
229
|
+
expect(result).toContain("id: 'weather-project'");
|
|
230
|
+
expect(result).toContain("name: 'Weather Project'");
|
|
231
|
+
expect(result).toContain('graphs: () => [weatherGraph]');
|
|
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
|
+
graphs: () => [],
|
|
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('graphs: () => []');
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
});
|
|
@@ -81,7 +81,14 @@ describe('createAgents - Template and Project ID Logic', () => {
|
|
|
81
81
|
// Should clone base template and weather-project template
|
|
82
82
|
expect(cloneTemplate).toHaveBeenCalledTimes(2);
|
|
83
83
|
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/create-agents-template', expect.any(String));
|
|
84
|
-
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents-cookbook/template-projects/weather-project', 'src/weather-project'
|
|
84
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents-cookbook/template-projects/weather-project', 'src/weather-project', expect.arrayContaining([
|
|
85
|
+
expect.objectContaining({
|
|
86
|
+
filePath: 'index.ts',
|
|
87
|
+
replacements: expect.objectContaining({
|
|
88
|
+
models: expect.any(Object)
|
|
89
|
+
})
|
|
90
|
+
})
|
|
91
|
+
]));
|
|
85
92
|
// Should not call getAvailableTemplates since no template validation needed
|
|
86
93
|
expect(getAvailableTemplates).not.toHaveBeenCalled();
|
|
87
94
|
});
|
|
@@ -91,8 +98,10 @@ describe('createAgents - Template and Project ID Logic', () => {
|
|
|
91
98
|
openAiKey: 'test-openai-key',
|
|
92
99
|
anthropicKey: 'test-anthropic-key',
|
|
93
100
|
});
|
|
94
|
-
// Check that
|
|
95
|
-
expect(fs.writeFile).toHaveBeenCalledWith('
|
|
101
|
+
// Check that .env file is created
|
|
102
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
103
|
+
// Check that inkeep.config.ts is created in src directory
|
|
104
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
96
105
|
});
|
|
97
106
|
});
|
|
98
107
|
describe('Template provided', () => {
|
|
@@ -108,8 +117,18 @@ describe('createAgents - Template and Project ID Logic', () => {
|
|
|
108
117
|
// Should clone base template and the specified template
|
|
109
118
|
expect(cloneTemplate).toHaveBeenCalledTimes(2);
|
|
110
119
|
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/create-agents-template', expect.any(String));
|
|
111
|
-
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents-cookbook/template-projects/chatbot', 'src/chatbot'
|
|
112
|
-
|
|
120
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents-cookbook/template-projects/chatbot', 'src/chatbot', expect.arrayContaining([
|
|
121
|
+
expect.objectContaining({
|
|
122
|
+
filePath: 'index.ts',
|
|
123
|
+
replacements: expect.objectContaining({
|
|
124
|
+
models: expect.any(Object)
|
|
125
|
+
})
|
|
126
|
+
})
|
|
127
|
+
]));
|
|
128
|
+
// Check that .env file is created
|
|
129
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
130
|
+
// Check that inkeep.config.ts is created in src directory (not in project subdirectory)
|
|
131
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
113
132
|
});
|
|
114
133
|
it('should exit with error when template does not exist', async () => {
|
|
115
134
|
vi.mocked(getAvailableTemplates).mockResolvedValue(['weather-project', 'chatbot']);
|
|
@@ -153,7 +172,12 @@ describe('createAgents - Template and Project ID Logic', () => {
|
|
|
153
172
|
expect(getAvailableTemplates).not.toHaveBeenCalled();
|
|
154
173
|
// Should create empty project directory
|
|
155
174
|
expect(fs.ensureDir).toHaveBeenCalledWith('src/my-custom-project');
|
|
156
|
-
|
|
175
|
+
// Check that .env file is created
|
|
176
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
177
|
+
// Check that inkeep.config.ts is created in src directory
|
|
178
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
179
|
+
// Check that custom project index.ts is created
|
|
180
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/my-custom-project/index.ts', expect.stringContaining('id: "my-custom-project"'));
|
|
157
181
|
});
|
|
158
182
|
it('should prioritize custom project ID over template if both are provided', async () => {
|
|
159
183
|
await createAgents({
|
|
@@ -168,8 +192,12 @@ describe('createAgents - Template and Project ID Logic', () => {
|
|
|
168
192
|
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/create-agents-template', expect.any(String));
|
|
169
193
|
expect(getAvailableTemplates).not.toHaveBeenCalled();
|
|
170
194
|
expect(fs.ensureDir).toHaveBeenCalledWith('src/my-custom-project');
|
|
171
|
-
//
|
|
172
|
-
expect(fs.writeFile).toHaveBeenCalledWith('
|
|
195
|
+
// Check that .env file is created
|
|
196
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
197
|
+
// Check that inkeep.config.ts is created in src directory
|
|
198
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
199
|
+
// Check that custom project index.ts is created
|
|
200
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/my-custom-project/index.ts', expect.stringContaining('id: "my-custom-project"'));
|
|
173
201
|
});
|
|
174
202
|
});
|
|
175
203
|
describe('Edge cases and validation', () => {
|
|
@@ -185,7 +213,14 @@ describe('createAgents - Template and Project ID Logic', () => {
|
|
|
185
213
|
anthropicKey: 'test-key',
|
|
186
214
|
});
|
|
187
215
|
expect(cloneTemplate).toHaveBeenCalledTimes(2);
|
|
188
|
-
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents-cookbook/template-projects/my-complex-template', 'src/my-complex-template'
|
|
216
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents-cookbook/template-projects/my-complex-template', 'src/my-complex-template', expect.arrayContaining([
|
|
217
|
+
expect.objectContaining({
|
|
218
|
+
filePath: 'index.ts',
|
|
219
|
+
replacements: expect.objectContaining({
|
|
220
|
+
models: expect.any(Object)
|
|
221
|
+
})
|
|
222
|
+
})
|
|
223
|
+
]));
|
|
189
224
|
});
|
|
190
225
|
it('should handle custom project IDs with special characters', async () => {
|
|
191
226
|
await createAgents({
|
|
@@ -195,7 +230,12 @@ describe('createAgents - Template and Project ID Logic', () => {
|
|
|
195
230
|
anthropicKey: 'test-key',
|
|
196
231
|
});
|
|
197
232
|
expect(fs.ensureDir).toHaveBeenCalledWith('src/my_project-123');
|
|
198
|
-
|
|
233
|
+
// Check that .env file is created
|
|
234
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
235
|
+
// Check that inkeep.config.ts is created in src directory
|
|
236
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
237
|
+
// Check that custom project index.ts is created
|
|
238
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/my_project-123/index.ts', expect.stringContaining('id: "my_project-123"'));
|
|
199
239
|
});
|
|
200
240
|
it('should create correct folder structure for all scenarios', async () => {
|
|
201
241
|
// Test default
|
package/dist/templates.d.ts
CHANGED
|
@@ -1,2 +1,16 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface ContentReplacement {
|
|
2
|
+
/** Relative file path within the cloned template */
|
|
3
|
+
filePath: string;
|
|
4
|
+
/** Object property replacements - key is the property path, value is the replacement content */
|
|
5
|
+
replacements: Record<string, any>;
|
|
6
|
+
}
|
|
7
|
+
export declare function cloneTemplate(templatePath: string, targetPath: string, replacements?: ContentReplacement[]): Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Replace content in cloned template files
|
|
10
|
+
*/
|
|
11
|
+
export declare function replaceContentInFiles(targetPath: string, replacements: ContentReplacement[]): Promise<void>;
|
|
12
|
+
/**
|
|
13
|
+
* Replace object properties in TypeScript code content
|
|
14
|
+
*/
|
|
15
|
+
export declare function replaceObjectProperties(content: string, replacements: Record<string, any>): Promise<string>;
|
|
2
16
|
export declare function getAvailableTemplates(): Promise<string[]>;
|
package/dist/templates.js
CHANGED
|
@@ -1,17 +1,242 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
1
2
|
import degit from 'degit';
|
|
2
3
|
import fs from 'fs-extra';
|
|
3
4
|
//Duplicating function here so we dont have to add a dependency on the agents-cli package
|
|
4
|
-
export async function cloneTemplate(templatePath, targetPath) {
|
|
5
|
+
export async function cloneTemplate(templatePath, targetPath, replacements) {
|
|
5
6
|
await fs.mkdir(targetPath, { recursive: true });
|
|
6
7
|
const templatePathSuffix = templatePath.replace('https://github.com/', '');
|
|
7
8
|
const emitter = degit(templatePathSuffix);
|
|
8
9
|
try {
|
|
9
10
|
await emitter.clone(targetPath);
|
|
11
|
+
// Apply content replacements if provided
|
|
12
|
+
if (replacements && replacements.length > 0) {
|
|
13
|
+
await replaceContentInFiles(targetPath, replacements);
|
|
14
|
+
}
|
|
10
15
|
}
|
|
11
16
|
catch (_error) {
|
|
12
17
|
process.exit(1);
|
|
13
18
|
}
|
|
14
19
|
}
|
|
20
|
+
/**
|
|
21
|
+
* Replace content in cloned template files
|
|
22
|
+
*/
|
|
23
|
+
export async function replaceContentInFiles(targetPath, replacements) {
|
|
24
|
+
for (const replacement of replacements) {
|
|
25
|
+
const filePath = path.join(targetPath, replacement.filePath);
|
|
26
|
+
// Check if file exists
|
|
27
|
+
if (!(await fs.pathExists(filePath))) {
|
|
28
|
+
console.warn(`Warning: File ${filePath} not found, skipping replacements`);
|
|
29
|
+
continue;
|
|
30
|
+
}
|
|
31
|
+
// Read the file content
|
|
32
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
33
|
+
// Apply replacements
|
|
34
|
+
const updatedContent = await replaceObjectProperties(content, replacement.replacements);
|
|
35
|
+
// Write back to file
|
|
36
|
+
await fs.writeFile(filePath, updatedContent, 'utf-8');
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Replace object properties in TypeScript code content
|
|
41
|
+
*/
|
|
42
|
+
export async function replaceObjectProperties(content, replacements) {
|
|
43
|
+
let updatedContent = content;
|
|
44
|
+
for (const [propertyPath, replacement] of Object.entries(replacements)) {
|
|
45
|
+
updatedContent = replaceObjectProperty(updatedContent, propertyPath, replacement);
|
|
46
|
+
}
|
|
47
|
+
return updatedContent;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Replace a specific object property in TypeScript code
|
|
51
|
+
* This implementation uses line-by-line parsing for better accuracy
|
|
52
|
+
* If the property doesn't exist, it will be added to the object
|
|
53
|
+
*/
|
|
54
|
+
function replaceObjectProperty(content, propertyPath, replacement) {
|
|
55
|
+
// Check if this is a single-line object format first (object all on one line)
|
|
56
|
+
const singleLineMatch = content.match(new RegExp(`^(.+{[^{}]*${propertyPath}\\s*:\\s*{[^{}]*}[^{}]*}.*)$`, 'm'));
|
|
57
|
+
if (singleLineMatch) {
|
|
58
|
+
// For single-line objects, handle replacement inline
|
|
59
|
+
const singleLinePattern = new RegExp(`((^|\\s|{)${propertyPath}\\s*:\\s*)({[^}]*})`);
|
|
60
|
+
return content.replace(singleLinePattern, `$1${JSON.stringify(replacement).replace(/"/g, "'").replace(/:/g, ': ').replace(/,/g, ', ')}`);
|
|
61
|
+
}
|
|
62
|
+
// Convert replacement to formatted JSON string with proper indentation
|
|
63
|
+
const replacementStr = JSON.stringify(replacement, null, 2).replace(/"/g, "'"); // Use single quotes for consistency with TS
|
|
64
|
+
const lines = content.split('\n');
|
|
65
|
+
const result = [];
|
|
66
|
+
let inTargetProperty = false;
|
|
67
|
+
let braceCount = 0;
|
|
68
|
+
let targetPropertyIndent = '';
|
|
69
|
+
let foundProperty = false;
|
|
70
|
+
for (let i = 0; i < lines.length; i++) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
const trimmedLine = line.trim();
|
|
73
|
+
// Skip if we're currently inside the target property
|
|
74
|
+
if (inTargetProperty) {
|
|
75
|
+
// Count braces to track nesting
|
|
76
|
+
for (const char of line) {
|
|
77
|
+
if (char === '{')
|
|
78
|
+
braceCount++;
|
|
79
|
+
if (char === '}')
|
|
80
|
+
braceCount--;
|
|
81
|
+
}
|
|
82
|
+
// When braceCount reaches 0, we've found the end of the property
|
|
83
|
+
if (braceCount <= 0) {
|
|
84
|
+
// Check if there's a trailing comma on this line or the original property line
|
|
85
|
+
const hasTrailingComma = line.includes(',') ||
|
|
86
|
+
(i + 1 < lines.length &&
|
|
87
|
+
lines[i + 1].trim().startsWith('}') === false &&
|
|
88
|
+
lines[i + 1].trim() !== '');
|
|
89
|
+
// Add the replacement with proper indentation
|
|
90
|
+
const indentedReplacement = replacementStr
|
|
91
|
+
.split('\n')
|
|
92
|
+
.map((replacementLine, index) => {
|
|
93
|
+
if (index === 0) {
|
|
94
|
+
return `${targetPropertyIndent}${propertyPath}: ${replacementLine}`;
|
|
95
|
+
}
|
|
96
|
+
return `${targetPropertyIndent}${replacementLine}`;
|
|
97
|
+
})
|
|
98
|
+
.join('\n');
|
|
99
|
+
result.push(`${indentedReplacement}${hasTrailingComma ? ',' : ''}`);
|
|
100
|
+
inTargetProperty = false;
|
|
101
|
+
foundProperty = true;
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
// Skip all lines while inside the target property
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
// Check if this line contains the target property at the right level
|
|
108
|
+
const propertyPattern = new RegExp(`(^|\\s+)${propertyPath}\\s*:`);
|
|
109
|
+
if (trimmedLine.startsWith(`${propertyPath}:`) || propertyPattern.test(line)) {
|
|
110
|
+
inTargetProperty = true;
|
|
111
|
+
braceCount = 0;
|
|
112
|
+
// For single-line objects, use base indentation plus 2 spaces for properties
|
|
113
|
+
if (line.includes(' = { ')) {
|
|
114
|
+
// Single-line format: use base indentation
|
|
115
|
+
targetPropertyIndent = `${line.match(/^\s*/)?.[0] || ''} `;
|
|
116
|
+
}
|
|
117
|
+
else {
|
|
118
|
+
// Multi-line format: calculate from property position
|
|
119
|
+
const propertyMatch = line.match(new RegExp(`(.*?)(^|\\s+)${propertyPath}\\s*:`));
|
|
120
|
+
targetPropertyIndent = propertyMatch ? propertyMatch[1] : line.match(/^\s*/)?.[0] || '';
|
|
121
|
+
}
|
|
122
|
+
// Count braces in the current line
|
|
123
|
+
for (const char of line) {
|
|
124
|
+
if (char === '{')
|
|
125
|
+
braceCount++;
|
|
126
|
+
if (char === '}')
|
|
127
|
+
braceCount--;
|
|
128
|
+
}
|
|
129
|
+
// If the property definition is on a single line (braceCount = 0)
|
|
130
|
+
if (braceCount <= 0) {
|
|
131
|
+
const hasTrailingComma = line.includes(',');
|
|
132
|
+
const indentedReplacement = replacementStr
|
|
133
|
+
.split('\n')
|
|
134
|
+
.map((replacementLine, index) => {
|
|
135
|
+
if (index === 0) {
|
|
136
|
+
return `${targetPropertyIndent}${propertyPath}: ${replacementLine}`;
|
|
137
|
+
}
|
|
138
|
+
return `${targetPropertyIndent}${replacementLine}`;
|
|
139
|
+
})
|
|
140
|
+
.join('\n');
|
|
141
|
+
result.push(`${indentedReplacement}${hasTrailingComma ? ',' : ''}`);
|
|
142
|
+
inTargetProperty = false;
|
|
143
|
+
foundProperty = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
// Continue to next iteration to process multi-line property
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
// If we're not in the target property, keep the line as-is
|
|
150
|
+
result.push(line);
|
|
151
|
+
}
|
|
152
|
+
// If property wasn't found, try to inject it into the object
|
|
153
|
+
if (!foundProperty) {
|
|
154
|
+
return injectPropertyIntoObject(result.join('\n'), propertyPath, replacement);
|
|
155
|
+
}
|
|
156
|
+
return result.join('\n');
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Inject a new property into a TypeScript object when the property doesn't exist
|
|
160
|
+
*/
|
|
161
|
+
function injectPropertyIntoObject(content, propertyPath, replacement) {
|
|
162
|
+
const replacementStr = JSON.stringify(replacement, null, 2).replace(/"/g, "'"); // Use single quotes for consistency with TS
|
|
163
|
+
const lines = content.split('\n');
|
|
164
|
+
const result = [];
|
|
165
|
+
// Find the main object definition (looking for patterns like project({...})
|
|
166
|
+
let foundObjectStart = false;
|
|
167
|
+
let objectDepth = 0;
|
|
168
|
+
let insertionPoint = -1;
|
|
169
|
+
let baseIndent = '';
|
|
170
|
+
for (let i = 0; i < lines.length; i++) {
|
|
171
|
+
const line = lines[i];
|
|
172
|
+
const trimmedLine = line.trim();
|
|
173
|
+
// Look for object patterns like "project({", "= {", etc.
|
|
174
|
+
if (!foundObjectStart &&
|
|
175
|
+
(trimmedLine.includes('({') || trimmedLine.endsWith(' = {') || line.includes(' = { '))) {
|
|
176
|
+
foundObjectStart = true;
|
|
177
|
+
baseIndent = line.match(/^\s*/)?.[0] || '';
|
|
178
|
+
objectDepth = 0;
|
|
179
|
+
// Count braces on this line
|
|
180
|
+
for (const char of line) {
|
|
181
|
+
if (char === '{')
|
|
182
|
+
objectDepth++;
|
|
183
|
+
if (char === '}')
|
|
184
|
+
objectDepth--;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else if (foundObjectStart) {
|
|
188
|
+
// Track brace depth
|
|
189
|
+
for (const char of line) {
|
|
190
|
+
if (char === '{')
|
|
191
|
+
objectDepth++;
|
|
192
|
+
if (char === '}')
|
|
193
|
+
objectDepth--;
|
|
194
|
+
}
|
|
195
|
+
// If we're at the end of the main object, this is our insertion point
|
|
196
|
+
if (objectDepth === 0 && trimmedLine.startsWith('}')) {
|
|
197
|
+
insertionPoint = i;
|
|
198
|
+
break;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
// If we found an insertion point, add the property
|
|
203
|
+
if (insertionPoint !== -1) {
|
|
204
|
+
const propertyIndent = `${baseIndent} `; // Add 2 spaces for property indent
|
|
205
|
+
// Check if we need a comma before our property
|
|
206
|
+
let needsCommaPrefix = false;
|
|
207
|
+
if (insertionPoint > 0) {
|
|
208
|
+
const prevLine = lines[insertionPoint - 1].trim();
|
|
209
|
+
needsCommaPrefix = prevLine !== '' && !prevLine.endsWith(',') && !prevLine.startsWith('}');
|
|
210
|
+
}
|
|
211
|
+
// Format the property to inject
|
|
212
|
+
const indentedReplacement = replacementStr
|
|
213
|
+
.split('\n')
|
|
214
|
+
.map((replacementLine, index) => {
|
|
215
|
+
if (index === 0) {
|
|
216
|
+
return `${propertyIndent}${propertyPath}: ${replacementLine}`;
|
|
217
|
+
}
|
|
218
|
+
return `${propertyIndent}${replacementLine}`;
|
|
219
|
+
})
|
|
220
|
+
.join('\n');
|
|
221
|
+
// Insert the property before the closing brace
|
|
222
|
+
for (let i = 0; i < lines.length; i++) {
|
|
223
|
+
if (i === insertionPoint) {
|
|
224
|
+
result.push(indentedReplacement);
|
|
225
|
+
}
|
|
226
|
+
// Add comma to previous line if needed and we're at the right position
|
|
227
|
+
if (i === insertionPoint - 1 && needsCommaPrefix) {
|
|
228
|
+
result.push(`${lines[i]},`);
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
result.push(lines[i]);
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
return result.join('\n');
|
|
235
|
+
}
|
|
236
|
+
// If we couldn't find a suitable injection point, warn and return original
|
|
237
|
+
console.warn(`Could not inject property "${propertyPath}" - no suitable object found in content`);
|
|
238
|
+
return content;
|
|
239
|
+
}
|
|
15
240
|
export async function getAvailableTemplates() {
|
|
16
241
|
// Fetch the list of templates from your repo
|
|
17
242
|
const response = await fetch('https://api.github.com/repos/inkeep/agents-cookbook/contents/template-projects');
|
package/dist/utils.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import * as p from '@clack/prompts';
|
|
2
1
|
import { exec } from 'node:child_process';
|
|
3
|
-
import fs from 'fs-extra';
|
|
4
2
|
import path from 'node:path';
|
|
5
|
-
import color from 'picocolors';
|
|
6
3
|
import { promisify } from 'node:util';
|
|
4
|
+
import * as p from '@clack/prompts';
|
|
5
|
+
import fs from 'fs-extra';
|
|
6
|
+
import color from 'picocolors';
|
|
7
7
|
import { cloneTemplate, getAvailableTemplates } from './templates.js';
|
|
8
8
|
const execAsync = promisify(exec);
|
|
9
9
|
export const defaultGoogleModelConfigurations = {
|
|
@@ -215,7 +215,16 @@ export const createAgents = async (args = {}) => {
|
|
|
215
215
|
if (projectTemplateRepo) {
|
|
216
216
|
s.message('Creating project template folder...');
|
|
217
217
|
const templateTargetPath = `src/${projectId}`;
|
|
218
|
-
|
|
218
|
+
// Prepare content replacements for model settings
|
|
219
|
+
const contentReplacements = [
|
|
220
|
+
{
|
|
221
|
+
filePath: 'index.ts',
|
|
222
|
+
replacements: {
|
|
223
|
+
models: defaultModelSettings,
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
];
|
|
227
|
+
await cloneTemplate(projectTemplateRepo, templateTargetPath, contentReplacements);
|
|
219
228
|
}
|
|
220
229
|
else {
|
|
221
230
|
s.message('Creating empty project folder...');
|
|
@@ -299,14 +308,12 @@ async function createInkeepConfig(config) {
|
|
|
299
308
|
|
|
300
309
|
const config = defineConfig({
|
|
301
310
|
tenantId: "${config.tenantId}",
|
|
302
|
-
projectId: "${config.projectId}",
|
|
303
311
|
agentsManageApiUrl: 'http://localhost:3002',
|
|
304
312
|
agentsRunApiUrl: 'http://localhost:3003',
|
|
305
|
-
modelSettings: ${JSON.stringify(config.modelSettings, null, 2)},
|
|
306
313
|
});
|
|
307
314
|
|
|
308
315
|
export default config;`;
|
|
309
|
-
await fs.writeFile(`src
|
|
316
|
+
await fs.writeFile(`src/inkeep.config.ts`, inkeepConfig);
|
|
310
317
|
if (config.customProject) {
|
|
311
318
|
const customIndexContent = `import { project } from '@inkeep/agents-sdk';
|
|
312
319
|
|
|
@@ -315,6 +322,7 @@ export const myProject = project({
|
|
|
315
322
|
name: "${config.projectId}",
|
|
316
323
|
description: "",
|
|
317
324
|
graphs: () => [],
|
|
325
|
+
models: ${JSON.stringify(config.modelSettings, null, 2)},
|
|
318
326
|
});`;
|
|
319
327
|
await fs.writeFile(`src/${config.projectId}/index.ts`, customIndexContent);
|
|
320
328
|
}
|