@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.
- package/LICENSE.md +56 -0
- package/README.md +149 -0
- package/SUPPLEMENTAL_TERMS.md +40 -0
- package/dist/__tests__/e2e/quickstart.test.d.ts +1 -0
- package/dist/__tests__/e2e/quickstart.test.js +178 -0
- package/dist/__tests__/e2e/utils.d.ts +41 -0
- package/dist/__tests__/e2e/utils.js +217 -0
- package/dist/__tests__/templates.test.d.ts +1 -0
- package/dist/__tests__/templates.test.js +283 -0
- package/dist/__tests__/utils.test.d.ts +1 -0
- package/dist/__tests__/utils.test.js +333 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +40 -0
- package/dist/templates.d.ts +17 -0
- package/dist/templates.js +266 -0
- package/dist/utils.d.ts +46 -0
- package/dist/utils.js +460 -0
- package/package.json +74 -0
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import fs from 'fs-extra';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cloneTemplate, cloneTemplateLocal, getAvailableTemplates } from '../templates';
|
|
5
|
+
import { createAgents } from '../utils';
|
|
6
|
+
// Mock all dependencies
|
|
7
|
+
vi.mock('fs-extra');
|
|
8
|
+
vi.mock('../templates');
|
|
9
|
+
vi.mock('@clack/prompts');
|
|
10
|
+
vi.mock('child_process');
|
|
11
|
+
vi.mock('util');
|
|
12
|
+
// Setup default mocks
|
|
13
|
+
const mockSpinner = {
|
|
14
|
+
start: vi.fn().mockReturnThis(),
|
|
15
|
+
stop: vi.fn().mockReturnThis(),
|
|
16
|
+
message: vi.fn().mockReturnThis(),
|
|
17
|
+
};
|
|
18
|
+
describe('createAgents - Template and Project ID Logic', () => {
|
|
19
|
+
let processExitSpy;
|
|
20
|
+
let processChdirSpy;
|
|
21
|
+
beforeEach(() => {
|
|
22
|
+
vi.clearAllMocks();
|
|
23
|
+
// Mock process methods
|
|
24
|
+
processExitSpy = vi.spyOn(process, 'exit').mockImplementation((code) => {
|
|
25
|
+
// Only throw for exit(0) which is expected behavior in some tests
|
|
26
|
+
// Let exit(1) pass so we can see the actual error
|
|
27
|
+
if (code === 0) {
|
|
28
|
+
throw new Error('process.exit called');
|
|
29
|
+
}
|
|
30
|
+
// Don't actually exit for exit(1) in tests
|
|
31
|
+
return undefined;
|
|
32
|
+
});
|
|
33
|
+
processChdirSpy = vi.spyOn(process, 'chdir').mockImplementation(() => { });
|
|
34
|
+
// Setup default mocks for @clack/prompts
|
|
35
|
+
vi.mocked(p.intro).mockImplementation(() => { });
|
|
36
|
+
vi.mocked(p.outro).mockImplementation(() => { });
|
|
37
|
+
vi.mocked(p.cancel).mockImplementation(() => { });
|
|
38
|
+
vi.mocked(p.note).mockImplementation(() => { });
|
|
39
|
+
vi.mocked(p.text).mockResolvedValue('test-dir');
|
|
40
|
+
vi.mocked(p.password).mockResolvedValue('test-api-key');
|
|
41
|
+
vi.mocked(p.select).mockResolvedValue('dual');
|
|
42
|
+
vi.mocked(p.confirm).mockResolvedValue(false);
|
|
43
|
+
vi.mocked(p.spinner).mockReturnValue(mockSpinner);
|
|
44
|
+
vi.mocked(p.isCancel).mockReturnValue(false);
|
|
45
|
+
// Mock fs-extra
|
|
46
|
+
vi.mocked(fs.pathExists).mockResolvedValue(false);
|
|
47
|
+
vi.mocked(fs.ensureDir).mockResolvedValue(undefined);
|
|
48
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
49
|
+
vi.mocked(fs.mkdir).mockResolvedValue(undefined);
|
|
50
|
+
vi.mocked(fs.remove).mockResolvedValue(undefined);
|
|
51
|
+
// Mock templates
|
|
52
|
+
vi.mocked(getAvailableTemplates).mockResolvedValue([
|
|
53
|
+
'event-planner',
|
|
54
|
+
'chatbot',
|
|
55
|
+
'data-analysis',
|
|
56
|
+
]);
|
|
57
|
+
vi.mocked(cloneTemplate).mockResolvedValue(undefined);
|
|
58
|
+
vi.mocked(cloneTemplateLocal).mockResolvedValue(undefined);
|
|
59
|
+
// Mock util.promisify to return a mock exec function
|
|
60
|
+
const mockExecAsync = vi.fn().mockResolvedValue({ stdout: '', stderr: '' });
|
|
61
|
+
const util = require('node:util');
|
|
62
|
+
util.promisify = vi.fn(() => mockExecAsync);
|
|
63
|
+
// Mock child_process.spawn
|
|
64
|
+
const childProcess = require('node:child_process');
|
|
65
|
+
childProcess.spawn = vi.fn(() => ({
|
|
66
|
+
pid: 12345,
|
|
67
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
68
|
+
on: vi.fn(),
|
|
69
|
+
kill: vi.fn(),
|
|
70
|
+
}));
|
|
71
|
+
});
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
processExitSpy.mockRestore();
|
|
74
|
+
processChdirSpy.mockRestore();
|
|
75
|
+
});
|
|
76
|
+
describe('Default behavior (no template or customProjectId)', () => {
|
|
77
|
+
it('should use activies-planner as default template and project ID', async () => {
|
|
78
|
+
await createAgents({
|
|
79
|
+
dirName: 'test-dir',
|
|
80
|
+
openAiKey: 'test-openai-key',
|
|
81
|
+
anthropicKey: 'test-anthropic-key',
|
|
82
|
+
});
|
|
83
|
+
// Should clone base template and activities-planner template
|
|
84
|
+
expect(cloneTemplate).toHaveBeenCalledTimes(2);
|
|
85
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents/create-agents-template', expect.any(String), undefined);
|
|
86
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents/agents-cookbook/template-projects/activities-planner', 'src/projects/activities-planner', expect.arrayContaining([
|
|
87
|
+
expect.objectContaining({
|
|
88
|
+
filePath: 'index.ts',
|
|
89
|
+
replacements: expect.objectContaining({
|
|
90
|
+
models: expect.any(Object),
|
|
91
|
+
}),
|
|
92
|
+
}),
|
|
93
|
+
]));
|
|
94
|
+
// Should not call getAvailableTemplates since no template validation needed
|
|
95
|
+
expect(getAvailableTemplates).not.toHaveBeenCalled();
|
|
96
|
+
});
|
|
97
|
+
it('should create project with event-planner as project ID', async () => {
|
|
98
|
+
await createAgents({
|
|
99
|
+
dirName: 'test-dir',
|
|
100
|
+
openAiKey: 'test-openai-key',
|
|
101
|
+
anthropicKey: 'test-anthropic-key',
|
|
102
|
+
});
|
|
103
|
+
// Check that .env file is created
|
|
104
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
105
|
+
// Check that inkeep.config.ts is created in src directory
|
|
106
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
describe('Template provided', () => {
|
|
110
|
+
it('should use template name as project ID when template is provided', async () => {
|
|
111
|
+
await createAgents({
|
|
112
|
+
dirName: 'test-dir',
|
|
113
|
+
template: 'chatbot',
|
|
114
|
+
openAiKey: 'test-openai-key',
|
|
115
|
+
anthropicKey: 'test-anthropic-key',
|
|
116
|
+
});
|
|
117
|
+
// Should validate template exists
|
|
118
|
+
expect(getAvailableTemplates).toHaveBeenCalled();
|
|
119
|
+
// Should clone base template and the specified template
|
|
120
|
+
expect(cloneTemplate).toHaveBeenCalledTimes(2);
|
|
121
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents/create-agents-template', expect.any(String), undefined);
|
|
122
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents/agents-cookbook/template-projects/chatbot', 'src/projects/chatbot', expect.arrayContaining([
|
|
123
|
+
expect.objectContaining({
|
|
124
|
+
filePath: 'index.ts',
|
|
125
|
+
replacements: expect.objectContaining({
|
|
126
|
+
models: expect.any(Object),
|
|
127
|
+
}),
|
|
128
|
+
}),
|
|
129
|
+
]));
|
|
130
|
+
// Check that .env file is created
|
|
131
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
132
|
+
// Check that inkeep.config.ts is created in src directory (not in project subdirectory)
|
|
133
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
134
|
+
});
|
|
135
|
+
it('should exit with error when template does not exist', async () => {
|
|
136
|
+
vi.mocked(getAvailableTemplates).mockResolvedValue(['event-planner', 'chatbot']);
|
|
137
|
+
await expect(createAgents({
|
|
138
|
+
dirName: 'test-dir',
|
|
139
|
+
template: 'non-existent-template',
|
|
140
|
+
openAiKey: 'test-openai-key',
|
|
141
|
+
})).rejects.toThrow('process.exit called');
|
|
142
|
+
expect(p.cancel).toHaveBeenCalledWith(expect.stringContaining('Template "non-existent-template" not found'));
|
|
143
|
+
expect(processExitSpy).toHaveBeenCalledWith(0);
|
|
144
|
+
});
|
|
145
|
+
it('should show available templates when invalid template is provided', async () => {
|
|
146
|
+
vi.mocked(getAvailableTemplates).mockResolvedValue([
|
|
147
|
+
'event-planner',
|
|
148
|
+
'chatbot',
|
|
149
|
+
'data-analysis',
|
|
150
|
+
]);
|
|
151
|
+
await expect(createAgents({
|
|
152
|
+
dirName: 'test-dir',
|
|
153
|
+
template: 'invalid',
|
|
154
|
+
openAiKey: 'test-openai-key',
|
|
155
|
+
})).rejects.toThrow('process.exit called');
|
|
156
|
+
const cancelCall = vi.mocked(p.cancel).mock.calls[0][0];
|
|
157
|
+
expect(cancelCall).toContain('event-planner');
|
|
158
|
+
expect(cancelCall).toContain('chatbot');
|
|
159
|
+
expect(cancelCall).toContain('data-analysis');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
describe('Custom Project ID provided', () => {
|
|
163
|
+
it('should use custom project ID and not clone any template', async () => {
|
|
164
|
+
await createAgents({
|
|
165
|
+
dirName: 'test-dir',
|
|
166
|
+
customProjectId: 'my-custom-project',
|
|
167
|
+
openAiKey: 'test-openai-key',
|
|
168
|
+
anthropicKey: 'test-anthropic-key',
|
|
169
|
+
});
|
|
170
|
+
// Should clone base template but NOT project template
|
|
171
|
+
expect(cloneTemplate).toHaveBeenCalledTimes(1);
|
|
172
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents/create-agents-template', expect.any(String), undefined);
|
|
173
|
+
// Should NOT validate templates
|
|
174
|
+
expect(getAvailableTemplates).not.toHaveBeenCalled();
|
|
175
|
+
// Should create empty project directory
|
|
176
|
+
expect(fs.ensureDir).toHaveBeenCalledWith('src/projects/my-custom-project');
|
|
177
|
+
// Check that .env file is created
|
|
178
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
179
|
+
// Check that inkeep.config.ts is created in src directory
|
|
180
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
181
|
+
// Check that custom project index.ts is created
|
|
182
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/projects/my-custom-project/index.ts', expect.stringContaining('id: "my-custom-project"'));
|
|
183
|
+
});
|
|
184
|
+
it('should prioritize custom project ID over template if both are provided', async () => {
|
|
185
|
+
await createAgents({
|
|
186
|
+
dirName: 'test-dir',
|
|
187
|
+
template: 'chatbot',
|
|
188
|
+
customProjectId: 'my-custom-project',
|
|
189
|
+
openAiKey: 'test-openai-key',
|
|
190
|
+
anthropicKey: 'test-anthropic-key',
|
|
191
|
+
});
|
|
192
|
+
// Should only clone base template, not project template
|
|
193
|
+
expect(cloneTemplate).toHaveBeenCalledTimes(1);
|
|
194
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents/create-agents-template', expect.any(String), undefined);
|
|
195
|
+
expect(getAvailableTemplates).not.toHaveBeenCalled();
|
|
196
|
+
expect(fs.ensureDir).toHaveBeenCalledWith('src/projects/my-custom-project');
|
|
197
|
+
// Check that .env file is created
|
|
198
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
199
|
+
// Check that inkeep.config.ts is created in src directory
|
|
200
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
201
|
+
// Check that custom project index.ts is created
|
|
202
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/projects/my-custom-project/index.ts', expect.stringContaining('id: "my-custom-project"'));
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
describe('Edge cases and validation', () => {
|
|
206
|
+
it('should handle template names with hyphens correctly', async () => {
|
|
207
|
+
vi.mocked(getAvailableTemplates).mockResolvedValue([
|
|
208
|
+
'my-complex-template',
|
|
209
|
+
'another-template',
|
|
210
|
+
]);
|
|
211
|
+
await createAgents({
|
|
212
|
+
dirName: 'test-dir',
|
|
213
|
+
template: 'my-complex-template',
|
|
214
|
+
openAiKey: 'test-key',
|
|
215
|
+
anthropicKey: 'test-key',
|
|
216
|
+
});
|
|
217
|
+
expect(cloneTemplate).toHaveBeenCalledTimes(2);
|
|
218
|
+
expect(cloneTemplate).toHaveBeenCalledWith('https://github.com/inkeep/agents/agents-cookbook/template-projects/my-complex-template', 'src/projects/my-complex-template', expect.arrayContaining([
|
|
219
|
+
expect.objectContaining({
|
|
220
|
+
filePath: 'index.ts',
|
|
221
|
+
replacements: expect.objectContaining({
|
|
222
|
+
models: expect.any(Object),
|
|
223
|
+
}),
|
|
224
|
+
}),
|
|
225
|
+
]));
|
|
226
|
+
});
|
|
227
|
+
it('should handle custom project IDs with special characters', async () => {
|
|
228
|
+
await createAgents({
|
|
229
|
+
dirName: 'test-dir',
|
|
230
|
+
customProjectId: 'my_project-123',
|
|
231
|
+
openAiKey: 'test-key',
|
|
232
|
+
anthropicKey: 'test-key',
|
|
233
|
+
});
|
|
234
|
+
expect(fs.ensureDir).toHaveBeenCalledWith('src/projects/my_project-123');
|
|
235
|
+
// Check that .env file is created
|
|
236
|
+
expect(fs.writeFile).toHaveBeenCalledWith('.env', expect.stringContaining('ENVIRONMENT=development'));
|
|
237
|
+
// Check that inkeep.config.ts is created in src directory
|
|
238
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/inkeep.config.ts', expect.stringContaining('tenantId: "default"'));
|
|
239
|
+
// Check that custom project index.ts is created
|
|
240
|
+
expect(fs.writeFile).toHaveBeenCalledWith('src/projects/my_project-123/index.ts', expect.stringContaining('id: "my_project-123"'));
|
|
241
|
+
});
|
|
242
|
+
it('should create correct folder structure for all scenarios', async () => {
|
|
243
|
+
// Test default
|
|
244
|
+
await createAgents({
|
|
245
|
+
dirName: 'dir1',
|
|
246
|
+
openAiKey: 'key',
|
|
247
|
+
anthropicKey: 'key',
|
|
248
|
+
});
|
|
249
|
+
expect(fs.ensureDir).toHaveBeenCalledWith('src');
|
|
250
|
+
// Reset mocks
|
|
251
|
+
vi.clearAllMocks();
|
|
252
|
+
setupDefaultMocks();
|
|
253
|
+
// Test with template
|
|
254
|
+
await createAgents({
|
|
255
|
+
dirName: 'dir2',
|
|
256
|
+
template: 'chatbot',
|
|
257
|
+
openAiKey: 'key',
|
|
258
|
+
anthropicKey: 'key',
|
|
259
|
+
});
|
|
260
|
+
expect(fs.ensureDir).toHaveBeenCalledWith('src');
|
|
261
|
+
// Reset mocks
|
|
262
|
+
vi.clearAllMocks();
|
|
263
|
+
setupDefaultMocks();
|
|
264
|
+
// Test with custom ID
|
|
265
|
+
await createAgents({
|
|
266
|
+
dirName: 'dir3',
|
|
267
|
+
customProjectId: 'custom',
|
|
268
|
+
openAiKey: 'key',
|
|
269
|
+
anthropicKey: 'key',
|
|
270
|
+
});
|
|
271
|
+
expect(fs.ensureDir).toHaveBeenCalledWith('src');
|
|
272
|
+
expect(fs.ensureDir).toHaveBeenCalledWith('src/projects/custom');
|
|
273
|
+
});
|
|
274
|
+
});
|
|
275
|
+
describe('Security - Password input for API keys', () => {
|
|
276
|
+
it('should use password input instead of text input for API keys', async () => {
|
|
277
|
+
// Mock the select to return 'anthropic' to trigger the API key prompt
|
|
278
|
+
vi.mocked(p.select).mockResolvedValueOnce('anthropic');
|
|
279
|
+
vi.mocked(p.password).mockResolvedValueOnce('test-anthropic-key');
|
|
280
|
+
await createAgents({
|
|
281
|
+
dirName: 'test-dir',
|
|
282
|
+
});
|
|
283
|
+
// Verify that p.password was called for the API key
|
|
284
|
+
expect(p.password).toHaveBeenCalledWith(expect.objectContaining({
|
|
285
|
+
message: 'Enter your Anthropic API key:',
|
|
286
|
+
validate: expect.any(Function),
|
|
287
|
+
}));
|
|
288
|
+
// Verify that p.text was NOT called for API keys (only for directory name)
|
|
289
|
+
const textCalls = vi.mocked(p.text).mock.calls;
|
|
290
|
+
const apiKeyCalls = textCalls.filter((call) => call[0] &&
|
|
291
|
+
typeof call[0] === 'object' &&
|
|
292
|
+
'message' in call[0] &&
|
|
293
|
+
(call[0].message.includes('API key') ||
|
|
294
|
+
call[0].message.includes('Anthropic') ||
|
|
295
|
+
call[0].message.includes('OpenAI') ||
|
|
296
|
+
call[0].message.includes('Google')));
|
|
297
|
+
expect(apiKeyCalls).toHaveLength(0);
|
|
298
|
+
});
|
|
299
|
+
it('should use password input for OpenAI keys', async () => {
|
|
300
|
+
vi.mocked(p.select).mockResolvedValueOnce('openai');
|
|
301
|
+
vi.mocked(p.password).mockResolvedValueOnce('test-openai-key');
|
|
302
|
+
await createAgents({
|
|
303
|
+
dirName: 'test-dir',
|
|
304
|
+
});
|
|
305
|
+
expect(p.password).toHaveBeenCalledWith(expect.objectContaining({
|
|
306
|
+
message: 'Enter your OpenAI API key:',
|
|
307
|
+
validate: expect.any(Function),
|
|
308
|
+
}));
|
|
309
|
+
});
|
|
310
|
+
it('should use password input for Google keys', async () => {
|
|
311
|
+
vi.mocked(p.select).mockResolvedValueOnce('google');
|
|
312
|
+
vi.mocked(p.password).mockResolvedValueOnce('test-google-key');
|
|
313
|
+
await createAgents({
|
|
314
|
+
dirName: 'test-dir',
|
|
315
|
+
});
|
|
316
|
+
expect(p.password).toHaveBeenCalledWith(expect.objectContaining({
|
|
317
|
+
message: 'Enter your Google API key:',
|
|
318
|
+
validate: expect.any(Function),
|
|
319
|
+
}));
|
|
320
|
+
});
|
|
321
|
+
});
|
|
322
|
+
});
|
|
323
|
+
// Helper to setup default mocks
|
|
324
|
+
function setupDefaultMocks() {
|
|
325
|
+
vi.mocked(p.spinner).mockReturnValue(mockSpinner);
|
|
326
|
+
vi.mocked(p.password).mockResolvedValue('test-api-key');
|
|
327
|
+
vi.mocked(fs.pathExists).mockResolvedValue(false);
|
|
328
|
+
vi.mocked(fs.ensureDir).mockResolvedValue(undefined);
|
|
329
|
+
vi.mocked(fs.writeFile).mockResolvedValue(undefined);
|
|
330
|
+
vi.mocked(getAvailableTemplates).mockResolvedValue(['event-planner', 'chatbot', 'data-analysis']);
|
|
331
|
+
vi.mocked(cloneTemplate).mockResolvedValue(undefined);
|
|
332
|
+
vi.mocked(cloneTemplateLocal).mockResolvedValue(undefined);
|
|
333
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { program } from 'commander';
|
|
3
|
+
import { createAgents } from './utils.js';
|
|
4
|
+
program
|
|
5
|
+
.name('create-agents')
|
|
6
|
+
.description('Create an Inkeep Agent Framework directory')
|
|
7
|
+
.version('0.1.0')
|
|
8
|
+
.argument('[directory-name]', 'Name of the directory')
|
|
9
|
+
.option('--template <template>', 'Template to use')
|
|
10
|
+
.option('--openai-key <openai-key>', 'OpenAI API key')
|
|
11
|
+
.option('--anthropic-key <anthropic-key>', 'Anthropic API key')
|
|
12
|
+
.option('--custom-project-id <custom-project-id>', 'Custom project id for experienced users who want an empty project directory')
|
|
13
|
+
.option('--disable-git', 'Disable git initialization')
|
|
14
|
+
.option('--local-agents-prefix <local-agents-prefix>', 'Local prefix for create-agents-template')
|
|
15
|
+
.option('--local-templates-prefix <local-templates-prefix>', 'Local prefix for project templates')
|
|
16
|
+
.parse();
|
|
17
|
+
async function main() {
|
|
18
|
+
const options = program.opts();
|
|
19
|
+
const directoryName = program.args[0];
|
|
20
|
+
try {
|
|
21
|
+
await createAgents({
|
|
22
|
+
dirName: directoryName,
|
|
23
|
+
openAiKey: options.openaiKey,
|
|
24
|
+
anthropicKey: options.anthropicKey,
|
|
25
|
+
customProjectId: options.customProjectId,
|
|
26
|
+
template: options.template,
|
|
27
|
+
disableGit: options.disableGit,
|
|
28
|
+
localAgentsPrefix: options.localAgentsPrefix,
|
|
29
|
+
localTemplatesPrefix: options.localTemplatesPrefix,
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
console.error('Failed to create directory:', error);
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
main().catch((error) => {
|
|
38
|
+
console.error('An unexpected error occurred:', error);
|
|
39
|
+
process.exit(1);
|
|
40
|
+
});
|
|
@@ -0,0 +1,17 @@
|
|
|
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
|
+
export declare function cloneTemplateLocal(templatePath: string, targetPath: string, replacements?: ContentReplacement[]): Promise<void>;
|
|
9
|
+
/**
|
|
10
|
+
* Replace content in cloned template files
|
|
11
|
+
*/
|
|
12
|
+
export declare function replaceContentInFiles(targetPath: string, replacements: ContentReplacement[]): Promise<void>;
|
|
13
|
+
/**
|
|
14
|
+
* Replace object properties in TypeScript code content
|
|
15
|
+
*/
|
|
16
|
+
export declare function replaceObjectProperties(content: string, replacements: Record<string, any>): Promise<string>;
|
|
17
|
+
export declare function getAvailableTemplates(localPrefix?: string): Promise<string[]>;
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import degit from 'degit';
|
|
3
|
+
import fs from 'fs-extra';
|
|
4
|
+
//Duplicating function here so we dont have to add a dependency on the agents-cli package
|
|
5
|
+
export async function cloneTemplate(templatePath, targetPath, replacements) {
|
|
6
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
7
|
+
const templatePathSuffix = templatePath.replace('https://github.com/', '');
|
|
8
|
+
const emitter = degit(templatePathSuffix);
|
|
9
|
+
try {
|
|
10
|
+
await emitter.clone(targetPath);
|
|
11
|
+
// Apply content replacements if provided
|
|
12
|
+
if (replacements && replacements.length > 0) {
|
|
13
|
+
await replaceContentInFiles(targetPath, replacements);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
catch (_error) {
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function cloneTemplateLocal(templatePath, targetPath, replacements) {
|
|
21
|
+
await fs.mkdir(targetPath, { recursive: true });
|
|
22
|
+
try {
|
|
23
|
+
await fs.copy(templatePath, targetPath, {
|
|
24
|
+
overwrite: true,
|
|
25
|
+
errorOnExist: false,
|
|
26
|
+
});
|
|
27
|
+
if (replacements && replacements.length > 0) {
|
|
28
|
+
await replaceContentInFiles(targetPath, replacements);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
console.error(`Failed to clone template from ${templatePath}:`, error);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Replace content in cloned template files
|
|
38
|
+
*/
|
|
39
|
+
export async function replaceContentInFiles(targetPath, replacements) {
|
|
40
|
+
for (const replacement of replacements) {
|
|
41
|
+
const filePath = path.join(targetPath, replacement.filePath);
|
|
42
|
+
// Check if file exists
|
|
43
|
+
if (!(await fs.pathExists(filePath))) {
|
|
44
|
+
console.warn(`Warning: File ${filePath} not found, skipping replacements`);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
// Read the file content
|
|
48
|
+
const content = await fs.readFile(filePath, 'utf-8');
|
|
49
|
+
// Apply replacements
|
|
50
|
+
const updatedContent = await replaceObjectProperties(content, replacement.replacements);
|
|
51
|
+
// Write back to file
|
|
52
|
+
await fs.writeFile(filePath, updatedContent, 'utf-8');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Replace object properties in TypeScript code content
|
|
57
|
+
*/
|
|
58
|
+
export async function replaceObjectProperties(content, replacements) {
|
|
59
|
+
let updatedContent = content;
|
|
60
|
+
for (const [propertyPath, replacement] of Object.entries(replacements)) {
|
|
61
|
+
updatedContent = replaceObjectProperty(updatedContent, propertyPath, replacement);
|
|
62
|
+
}
|
|
63
|
+
return updatedContent;
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Replace a specific object property in TypeScript code
|
|
67
|
+
* This implementation uses line-by-line parsing for better accuracy
|
|
68
|
+
* If the property doesn't exist, it will be added to the object
|
|
69
|
+
*/
|
|
70
|
+
function replaceObjectProperty(content, propertyPath, replacement) {
|
|
71
|
+
// Check if this is a single-line object format first (object all on one line)
|
|
72
|
+
const singleLineMatch = content.match(new RegExp(`^(.+{[^{}]*${propertyPath}\\s*:\\s*{[^{}]*}[^{}]*}.*)$`, 'm'));
|
|
73
|
+
if (singleLineMatch) {
|
|
74
|
+
// For single-line objects, handle replacement inline
|
|
75
|
+
const singleLinePattern = new RegExp(`((^|\\s|{)${propertyPath}\\s*:\\s*)({[^}]*})`);
|
|
76
|
+
return content.replace(singleLinePattern, `$1${JSON.stringify(replacement).replace(/"/g, "'").replace(/:/g, ': ').replace(/,/g, ', ')}`);
|
|
77
|
+
}
|
|
78
|
+
// Convert replacement to formatted JSON string with proper indentation
|
|
79
|
+
const replacementStr = JSON.stringify(replacement, null, 2).replace(/"/g, "'"); // Use single quotes for consistency with TS
|
|
80
|
+
const lines = content.split('\n');
|
|
81
|
+
const result = [];
|
|
82
|
+
let inTargetProperty = false;
|
|
83
|
+
let braceCount = 0;
|
|
84
|
+
let targetPropertyIndent = '';
|
|
85
|
+
let foundProperty = false;
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i];
|
|
88
|
+
const trimmedLine = line.trim();
|
|
89
|
+
// Skip if we're currently inside the target property
|
|
90
|
+
if (inTargetProperty) {
|
|
91
|
+
// Count braces to track nesting
|
|
92
|
+
for (const char of line) {
|
|
93
|
+
if (char === '{')
|
|
94
|
+
braceCount++;
|
|
95
|
+
if (char === '}')
|
|
96
|
+
braceCount--;
|
|
97
|
+
}
|
|
98
|
+
// When braceCount reaches 0, we've found the end of the property
|
|
99
|
+
if (braceCount <= 0) {
|
|
100
|
+
// Check if there's a trailing comma on this line or the original property line
|
|
101
|
+
const hasTrailingComma = line.includes(',') ||
|
|
102
|
+
(i + 1 < lines.length &&
|
|
103
|
+
lines[i + 1].trim().startsWith('}') === false &&
|
|
104
|
+
lines[i + 1].trim() !== '');
|
|
105
|
+
// Add the replacement with proper indentation
|
|
106
|
+
const indentedReplacement = replacementStr
|
|
107
|
+
.split('\n')
|
|
108
|
+
.map((replacementLine, index) => {
|
|
109
|
+
if (index === 0) {
|
|
110
|
+
return `${targetPropertyIndent}${propertyPath}: ${replacementLine}`;
|
|
111
|
+
}
|
|
112
|
+
return `${targetPropertyIndent}${replacementLine}`;
|
|
113
|
+
})
|
|
114
|
+
.join('\n');
|
|
115
|
+
result.push(`${indentedReplacement}${hasTrailingComma ? ',' : ''}`);
|
|
116
|
+
inTargetProperty = false;
|
|
117
|
+
foundProperty = true;
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
// Skip all lines while inside the target property
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
// Check if this line contains the target property at the right level
|
|
124
|
+
const propertyPattern = new RegExp(`(^|\\s+)${propertyPath}\\s*:`);
|
|
125
|
+
if (trimmedLine.startsWith(`${propertyPath}:`) || propertyPattern.test(line)) {
|
|
126
|
+
inTargetProperty = true;
|
|
127
|
+
braceCount = 0;
|
|
128
|
+
// For single-line objects, use base indentation plus 2 spaces for properties
|
|
129
|
+
if (line.includes(' = { ')) {
|
|
130
|
+
// Single-line format: use base indentation
|
|
131
|
+
targetPropertyIndent = `${line.match(/^\s*/)?.[0] || ''} `;
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
// Multi-line format: calculate from property position
|
|
135
|
+
const propertyMatch = line.match(new RegExp(`(.*?)(^|\\s+)${propertyPath}\\s*:`));
|
|
136
|
+
targetPropertyIndent = propertyMatch ? propertyMatch[1] : line.match(/^\s*/)?.[0] || '';
|
|
137
|
+
}
|
|
138
|
+
// Count braces in the current line
|
|
139
|
+
for (const char of line) {
|
|
140
|
+
if (char === '{')
|
|
141
|
+
braceCount++;
|
|
142
|
+
if (char === '}')
|
|
143
|
+
braceCount--;
|
|
144
|
+
}
|
|
145
|
+
// If the property definition is on a single line (braceCount = 0)
|
|
146
|
+
if (braceCount <= 0) {
|
|
147
|
+
const hasTrailingComma = line.includes(',');
|
|
148
|
+
const indentedReplacement = replacementStr
|
|
149
|
+
.split('\n')
|
|
150
|
+
.map((replacementLine, index) => {
|
|
151
|
+
if (index === 0) {
|
|
152
|
+
return `${targetPropertyIndent}${propertyPath}: ${replacementLine}`;
|
|
153
|
+
}
|
|
154
|
+
return `${targetPropertyIndent}${replacementLine}`;
|
|
155
|
+
})
|
|
156
|
+
.join('\n');
|
|
157
|
+
result.push(`${indentedReplacement}${hasTrailingComma ? ',' : ''}`);
|
|
158
|
+
inTargetProperty = false;
|
|
159
|
+
foundProperty = true;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
// Continue to next iteration to process multi-line property
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
// If we're not in the target property, keep the line as-is
|
|
166
|
+
result.push(line);
|
|
167
|
+
}
|
|
168
|
+
// If property wasn't found, try to inject it into the object
|
|
169
|
+
if (!foundProperty) {
|
|
170
|
+
return injectPropertyIntoObject(result.join('\n'), propertyPath, replacement);
|
|
171
|
+
}
|
|
172
|
+
return result.join('\n');
|
|
173
|
+
}
|
|
174
|
+
/**
|
|
175
|
+
* Inject a new property into a TypeScript object when the property doesn't exist
|
|
176
|
+
*/
|
|
177
|
+
function injectPropertyIntoObject(content, propertyPath, replacement) {
|
|
178
|
+
const replacementStr = JSON.stringify(replacement, null, 2).replace(/"/g, "'"); // Use single quotes for consistency with TS
|
|
179
|
+
const lines = content.split('\n');
|
|
180
|
+
const result = [];
|
|
181
|
+
// Find the main object definition (looking for patterns like project({...})
|
|
182
|
+
let foundObjectStart = false;
|
|
183
|
+
let objectDepth = 0;
|
|
184
|
+
let insertionPoint = -1;
|
|
185
|
+
let baseIndent = '';
|
|
186
|
+
for (let i = 0; i < lines.length; i++) {
|
|
187
|
+
const line = lines[i];
|
|
188
|
+
const trimmedLine = line.trim();
|
|
189
|
+
// Look for object patterns like "project({", "= {", etc.
|
|
190
|
+
if (!foundObjectStart &&
|
|
191
|
+
(trimmedLine.includes('({') || trimmedLine.endsWith(' = {') || line.includes(' = { '))) {
|
|
192
|
+
foundObjectStart = true;
|
|
193
|
+
baseIndent = line.match(/^\s*/)?.[0] || '';
|
|
194
|
+
objectDepth = 0;
|
|
195
|
+
// Count braces on this line
|
|
196
|
+
for (const char of line) {
|
|
197
|
+
if (char === '{')
|
|
198
|
+
objectDepth++;
|
|
199
|
+
if (char === '}')
|
|
200
|
+
objectDepth--;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
else if (foundObjectStart) {
|
|
204
|
+
// Track brace depth
|
|
205
|
+
for (const char of line) {
|
|
206
|
+
if (char === '{')
|
|
207
|
+
objectDepth++;
|
|
208
|
+
if (char === '}')
|
|
209
|
+
objectDepth--;
|
|
210
|
+
}
|
|
211
|
+
// If we're at the end of the main object, this is our insertion point
|
|
212
|
+
if (objectDepth === 0 && trimmedLine.startsWith('}')) {
|
|
213
|
+
insertionPoint = i;
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
// If we found an insertion point, add the property
|
|
219
|
+
if (insertionPoint !== -1) {
|
|
220
|
+
const propertyIndent = `${baseIndent} `; // Add 2 spaces for property indent
|
|
221
|
+
// Check if we need a comma before our property
|
|
222
|
+
let needsCommaPrefix = false;
|
|
223
|
+
if (insertionPoint > 0) {
|
|
224
|
+
const prevLine = lines[insertionPoint - 1].trim();
|
|
225
|
+
needsCommaPrefix = prevLine !== '' && !prevLine.endsWith(',') && !prevLine.startsWith('}');
|
|
226
|
+
}
|
|
227
|
+
// Format the property to inject
|
|
228
|
+
const indentedReplacement = replacementStr
|
|
229
|
+
.split('\n')
|
|
230
|
+
.map((replacementLine, index) => {
|
|
231
|
+
if (index === 0) {
|
|
232
|
+
return `${propertyIndent}${propertyPath}: ${replacementLine}`;
|
|
233
|
+
}
|
|
234
|
+
return `${propertyIndent}${replacementLine}`;
|
|
235
|
+
})
|
|
236
|
+
.join('\n');
|
|
237
|
+
// Insert the property before the closing brace
|
|
238
|
+
for (let i = 0; i < lines.length; i++) {
|
|
239
|
+
if (i === insertionPoint) {
|
|
240
|
+
result.push(indentedReplacement);
|
|
241
|
+
}
|
|
242
|
+
// Add comma to previous line if needed and we're at the right position
|
|
243
|
+
if (i === insertionPoint - 1 && needsCommaPrefix) {
|
|
244
|
+
result.push(`${lines[i]},`);
|
|
245
|
+
}
|
|
246
|
+
else {
|
|
247
|
+
result.push(lines[i]);
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return result.join('\n');
|
|
251
|
+
}
|
|
252
|
+
// If we couldn't find a suitable injection point, warn and return original
|
|
253
|
+
console.warn(`Could not inject property "${propertyPath}" - no suitable object found in content`);
|
|
254
|
+
return content;
|
|
255
|
+
}
|
|
256
|
+
export async function getAvailableTemplates(localPrefix) {
|
|
257
|
+
if (localPrefix && localPrefix.length > 0) {
|
|
258
|
+
const fullTemplatePath = path.join(localPrefix, 'template-projects');
|
|
259
|
+
const response = await fs.readdir(fullTemplatePath);
|
|
260
|
+
return response.filter((item) => fs.stat(path.join(fullTemplatePath, item)).then((stat) => stat.isDirectory()));
|
|
261
|
+
}
|
|
262
|
+
// Fetch the list of templates from your repo
|
|
263
|
+
const response = await fetch(`https://api.github.com/repos/inkeep/agents/contents/agents-cookbook/template-projects`);
|
|
264
|
+
const contents = await response.json();
|
|
265
|
+
return contents.filter((item) => item.type === 'dir').map((item) => item.name);
|
|
266
|
+
}
|