@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,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
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
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
+ }