├── BACKLOG.md ├── .gitignore ├── src ├── options.ts ├── copyToClipboard.ts ├── readGlobalConfig.ts ├── lev.ts ├── fileReader.ts ├── filterFiles.ts ├── directoryTree.ts ├── copa.ts └── promptProcessor.ts ├── prompts ├── 1__new_feature.copa ├── update_readme.copa ├── doesnt_run.copa ├── fix-bug.copa ├── 2__fix-dir-git.copa ├── 3__add-test.copa ├── new_feature.copa ├── 1__new_feature-tests.copa ├── new_feature_tests.copa ├── failed_test.copa └── snips │ └── about_project.md ├── tsconfig.json ├── package.json ├── .github └── workflows │ └── publish.yml ├── LICENSE ├── tests ├── unicode.test.ts ├── sanity.test.ts ├── promptProcessorFileContentModifiers.test.ts └── promptProcessor.test.ts ├── README.md └── copa.svg /BACKLOG.md: -------------------------------------------------------------------------------- 1 | - count template tokens 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | dist 3 | .yarn 4 | version.sh 5 | .idea 6 | prompts/*.req 7 | prompts/*.md 8 | prompts/*.thinking 9 | -------------------------------------------------------------------------------- /src/options.ts: -------------------------------------------------------------------------------- 1 | export interface Options { 2 | exclude?: string; 3 | include?: string; 4 | verbose?: boolean; 5 | file?: string[]; 6 | } 7 | -------------------------------------------------------------------------------- /src/copyToClipboard.ts: -------------------------------------------------------------------------------- 1 | import {default as clipboardy} from "clipboardy" 2 | 3 | export async function copyToClipboard(content: string): Promise { 4 | const normalizedContent = content.normalize('NFC'); 5 | await clipboardy.write(normalizedContent); 6 | } 7 | -------------------------------------------------------------------------------- /prompts/1__new_feature.copa: -------------------------------------------------------------------------------- 1 | I'm working on an OSS project 2 | 3 | ``` 4 | {{@../src}} 5 | ``` 6 | 7 | I want to be able to specify positive filters. 8 | 9 | Right now I am able to filter out files / folders, but what I want to be able to do: 10 | 11 | `@packages:+package.json,+tsconfig.json,+*.json` -------------------------------------------------------------------------------- /prompts/update_readme.copa: -------------------------------------------------------------------------------- 1 | I'm working on an OSS project 2 | 3 | ```` 4 | {{@../README.md}} 5 | ```` 6 | 7 | This is the source code: 8 | 9 | ``` 10 | {{@../src}} 11 | ``` 12 | 13 | And tests: 14 | 15 | ``` 16 | {{@../tests}} 17 | ``` 18 | 19 | Can you help me update the README to be inline with the latest code and the examples seen in tests? 20 | -------------------------------------------------------------------------------- /prompts/doesnt_run.copa: -------------------------------------------------------------------------------- 1 | I'm working on an OSS project 2 | 3 | This is the source of it: 4 | 5 | ``` 6 | {{@../src}} 7 | ``` 8 | 9 | These are the current tests: 10 | 11 | ```` 12 | {{@../tests}} 13 | ```` 14 | 15 | I have an issue with unicode characters, specifically when I use my library to copy, 16 | it's turning `FCN: F × X → {` into `FCN: F √ó X ‚Üí {`. 17 | 18 | Can you help me set up a test for this? 19 | -------------------------------------------------------------------------------- /prompts/fix-bug.copa: -------------------------------------------------------------------------------- 1 | I'm working on an OSS project 2 | 3 | ``` 4 | {{@./snips/about_project.md}} 5 | ``` 6 | 7 | This is the source: 8 | 9 | ``` 10 | {{@../src}} 11 | ``` 12 | 13 | When I run tests, it this is what I get: 14 | ```` 15 | ===== component.tsx (imports removed) ===== 16 | ```` 17 | 18 | While expecting: 19 | ```` 20 | ===== src/components/component.tsx (imports removed) ===== 21 | ```` 22 | 23 | How can I fix this? -------------------------------------------------------------------------------- /prompts/2__fix-dir-git.copa: -------------------------------------------------------------------------------- 1 | I'm working on an OSS project 2 | 3 | ``` 4 | {{@./snips/about_project.md}} 5 | ``` 6 | 7 | This is the source: 8 | 9 | ``` 10 | {{@../src}} 11 | ``` 12 | 13 | The feature I'm working on is: 14 | - Add support for generating simple ASCII tree for a given location (assuming its a directory) 15 | For example when I do `@../src:dir`- this will be generated as directory structure instead of contents. 16 | 17 | The issue is that it's implemented using fs and not using `filterFiles`, as a result it's not respecting git ignore for example.. 18 | 19 | -------------------------------------------------------------------------------- /prompts/3__add-test.copa: -------------------------------------------------------------------------------- 1 | Some tests from my project 2 | 3 | ``` 4 | {{@../tests/promptProcessor.test.ts}} 5 | ``` 6 | 7 | As it stands, all tests pass. 8 | I recently added support for including files using "+" notation. 9 | 10 | This seems to work: 11 | @../:dir,+*.config.ts 12 | ``` 13 | ===== Directory Structure: ../ ===== 14 | mcpgate/ 15 | ├── packages/ 16 | │ ├── bridge/ 17 | │ │ └── vitest.config.ts 18 | │ ├── cli/ 19 | │ │ ├── tsup.config.ts 20 | │ │ └── vitest.config.ts 21 | │ ├── frontend/ 22 | │ │ └── vite.config.ts 23 | │ ├── gateway/ 24 | ``` 25 | But this returns nothing: 26 | @../packages:dir,+*.config.ts 27 | 28 | Can you help me add a test to reproduce this? -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@tsconfig/node16/tsconfig.json", 3 | "compilerOptions": { 4 | "declaration": true, 5 | "target": "es2022", 6 | "module": "CommonJS", 7 | "moduleResolution": "node", 8 | "rootDir": "src", 9 | "strict": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "lib": [ 14 | "ES2022" 15 | ], 16 | "outDir": "./dist", 17 | "resolveJsonModule": true, 18 | "sourceMap": true, 19 | "preserveConstEnums": true 20 | }, 21 | "include": [ 22 | "src/**/*" 23 | ], 24 | "exclude": [ 25 | "node_modules", 26 | "dist", 27 | "examples", 28 | "tests" 29 | ] 30 | } 31 | -------------------------------------------------------------------------------- /src/readGlobalConfig.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import os from "os"; 3 | import fs from "fs/promises"; 4 | 5 | export async function readGlobalConfig(): Promise { 6 | const configPath = path.join(os.homedir(), '.copa'); 7 | try { 8 | const configContent = await fs.readFile(configPath, 'utf-8'); 9 | const ignoreLine = configContent.split('\n').find(line => line.startsWith('ignore:')); 10 | if (ignoreLine) { 11 | return ignoreLine.split(':')[1].trim(); 12 | } 13 | } catch (error) { 14 | if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { 15 | console.warn('Warning: Unable to read global config file:', error); 16 | } 17 | } 18 | return ''; 19 | } 20 | -------------------------------------------------------------------------------- /prompts/new_feature.copa: -------------------------------------------------------------------------------- 1 | I'm working on an OSS project 2 | 3 | This is the source: 4 | 5 | ``` 6 | {{@../src/promptProcessor.ts}} 7 | ``` 8 | 9 | Normally when I add content I use ```{{ ... }}```, so I have to write backticks myself.. 10 | Plus, I need to be aware of how many backticks are in the content (so I have +1). 11 | What I would like to do is when I write {{{ the library will automatically check the most 12 | backticks in the content and surround it with +1, so: 13 | {{{ ... }}} will translate to (in case there are no three backticks inside) 14 | 15 | ``` 16 | injected content 17 | ``` 18 | 19 | So we added the "fenced block" feature, which works great. Can you help me update the 20 | README to include this feature? 21 | 22 | 23 | ``` 24 | {{@../README.md}} 25 | ``` -------------------------------------------------------------------------------- /src/lev.ts: -------------------------------------------------------------------------------- 1 | export const lev = (a: string, b: string): number => { 2 | if (a === b) return 0; 3 | if (!a.length) return b.length; 4 | if (!b.length) return a.length; 5 | if (a.length > b.length) [a, b] = [b, a]; // keep a the shorter 6 | 7 | const dp = Array(a.length + 1).fill(0); 8 | for (let i = 0; i <= a.length; i++) dp[i] = i; 9 | 10 | for (let j = 1; j <= b.length; j++) { 11 | let prevDiag = j - 1; // value from dp[i-1] before overwrite 12 | dp[0] = j; 13 | for (let i = 1; i <= a.length; i++) { 14 | const temp = dp[i]; 15 | const cost = a.charCodeAt(i - 1) === b.charCodeAt(j - 1) ? 0 : 1; 16 | dp[i] = Math.min( 17 | dp[i] + 1, // deletion 18 | dp[i - 1] + 1, // insertion 19 | prevDiag + cost // substitution 20 | ); 21 | prevDiag = temp; 22 | } 23 | } 24 | return dp[a.length]; 25 | }; -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "copa", 3 | "version": "1.6.2", 4 | "description": "CoPa: Prompt Engineering Templating Language and CLI Tool", 5 | "type": "commonjs", 6 | "main": "./dist/copa.js", 7 | "bin": "./dist/copa.js", 8 | "scripts": { 9 | "build": "tsc", 10 | "start": "node dist/copa.js", 11 | "test": "vitest" 12 | }, 13 | "author": "Roman Landenband", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "@types/jsdom": "^21.1.7", 17 | "@types/node": "^22.17.0", 18 | "ts-node": "^10.9.2", 19 | "typescript": "^5.9.2", 20 | "vitest": "^2.1.9" 21 | }, 22 | "dependencies": { 23 | "@dqbd/tiktoken": "^1.0.21", 24 | "clipboardy": "^4.0.0", 25 | "commander": "^12.1.0", 26 | "glob": "^11.0.3", 27 | "mammoth": "^1.10.0", 28 | "minimatch": "^10.0.3", 29 | "officeparser": "^5.2.0", 30 | "pdfjs-dist": "^5.4.54", 31 | "simple-git": "^3.28.0" 32 | }, 33 | "files": [ 34 | "dist/**/*" 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Publish to npm 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' # Triggers the workflow on version tags 7 | 8 | jobs: 9 | publish: 10 | runs-on: ubuntu-latest 11 | 12 | strategy: 13 | matrix: 14 | node-version: [ 20.x ] 15 | 16 | steps: 17 | - uses: actions/checkout@v4 18 | 19 | - name: Use Node.js ${{ matrix.node-version }} 20 | uses: actions/setup-node@v4 21 | with: 22 | node-version: ${{ matrix.node-version }} 23 | registry-url: 'https://registry.npmjs.org/' 24 | 25 | - run: corepack enable 26 | 27 | - name: Install root project dependencies 28 | run: yarn install 29 | env: 30 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 31 | 32 | - name: Build root project 33 | run: yarn build 34 | 35 | - name: Publish root project to npm 36 | run: yarn publish --access public 37 | env: 38 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 39 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Roman Landenband 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /prompts/1__new_feature-tests.copa: -------------------------------------------------------------------------------- 1 | 2 | This is the source: 3 | 4 | ``` 5 | {{@../src}} 6 | ``` 7 | 8 | Please help me add support for generating simple ASCII tree for a given location (assuming its a directory) 9 | For example when I do `@../src:dir`- this will be generated as directory structure instead of contents. 10 | 11 | The feature was implemented but some tests are failing, this is what the test expects: 12 | 13 | 14 | expect(result.content).toContain('Project structure:'); 15 | expect(result.content).toContain('===== Directory Structure: src ====='); 16 | expect(result.content).toContain('src'); 17 | expect(result.content).toContain('├── components/'); 18 | expect(result.content).toContain('│ ├── Button.js'); 19 | expect(result.content).toContain('│ └── Card.js'); 20 | expect(result.content).toContain('├── utils/'); 21 | expect(result.content).toContain('│ └── format.js'); 22 | expect(result.content).toContain('└── index.js'); 23 | 24 | 25 | But the result is: 26 | 27 | ├── components/ 28 | │ ├── Button.js 29 | │ ├── Card.js 30 | ├── utils/ 31 | │ ├── format.js 32 | ├── index.js 33 | -------------------------------------------------------------------------------- /prompts/new_feature_tests.copa: -------------------------------------------------------------------------------- 1 | I'm working on an OSS project 2 | 3 | ``` 4 | {{@./snips/about_project.md}} 5 | ``` 6 | 7 | This is the source: 8 | 9 | ``` 10 | {{@../src}} 11 | ``` 12 | 13 | Please help me test support for removing imports from imported sources files using `:remove-imports` modifier 14 | 15 | - applies when importing single file or a folder 16 | - per language handling by filename (`.ts`, `.tsx`) 17 | - for now just implement for `.ts` & `.tsx` 18 | 19 | Using this as reference: 20 | ```` 21 | describe('Prompt Processor with remove imports from file', () => { 22 | let testDir: string; 23 | 24 | const cleanPath = (path: string) => path.replace(testDir + '/', ''); 25 | 26 | beforeEach(async () => { 27 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'copa-prompt-test-')); 28 | await fs.mkdir(path.join(testDir, 'subdir')); 29 | await fs.writeFile(path.join(testDir, 'file1.js'), 'console.log("Hello");'); 30 | await fs.writeFile(path.join(testDir, 'file2.md'), '# Markdown'); 31 | await fs.writeFile(path.join(testDir, 'subdir', 'file3.txt'), 'Nested file content'); 32 | }); 33 | 34 | afterEach(async () => { 35 | await fs.rm(testDir, {recursive: true, force: true}); 36 | }); 37 | 38 | test('....', async () => { 39 | const promptContent = 'This is...'; 40 | const promptFile = path.join(testDir, 'prompt.txt'); 41 | await fs.writeFile(promptFile, promptContent); 42 | 43 | const result = await processPromptFile(promptFile); 44 | 45 | expect(...).toContain('...'); 46 | }); 47 | 48 | 49 | }) 50 | ```` -------------------------------------------------------------------------------- /prompts/failed_test.copa: -------------------------------------------------------------------------------- 1 | I'm working on an OSS project 2 | 3 | This is the source of it: 4 | 5 | ``` 6 | {{@../src}} 7 | ``` 8 | 9 | And tests: 10 | 11 | ``` 12 | {{@../tests}} 13 | ``` 14 | 15 | Some tests are failing, can you investigate? 16 | 17 | 18 | 19 | ● CoPa Functionality › filterFiles › excludes files based on command line options 20 | 21 | expect(received).toBe(expected) // Object.is equality 22 | 23 | Expected: 2 24 | Received: 6 25 | 26 | 39 | test('excludes files based on command line options', async () => { 27 | 40 | const files = await filterFiles({exclude: 'js,md'}, testDir); 28 | > 41 | expect(files.length).toBe(2); 29 | | ^ 30 | 42 | expect(files.sort()).toEqual([ 31 | 43 | 'file3.yml', 32 | 44 | path.join('subdir', 'file6.yml') 33 | 34 | at Object. (tests/sanity.test.ts:41:34) 35 | 36 | ● CoPa Functionality › filterFiles › excludes files based on single extension 37 | 38 | expect(received).toBe(expected) // Object.is equality 39 | 40 | Expected: 4 41 | Received: 6 42 | 43 | 48 | test('excludes files based on single extension', async () => { 44 | 49 | const files = await filterFiles({exclude: 'yml'}, testDir); 45 | > 50 | expect(files.length).toBe(4); 46 | | ^ 47 | 51 | expect(files.sort()).toEqual([ 48 | 52 | 'file1.js', 49 | 53 | 'file2.md', 50 | 51 | at Object. (tests/sanity.test.ts:50:34) 52 | 53 | ● hidden folders › filterFiles › excludes hidden folder and its files with glob pattern 54 | 55 | expect(received).toBe(expected) // Object.is equality 56 | 57 | Expected: 6 58 | Received: 7 59 | 60 | 131 | test('excludes hidden folder and its files with glob pattern', async () => { 61 | 132 | const files = await filterFiles({ exclude: '.*' }, testDir); 62 | > 133 | expect(files.length).toBe(6); 63 | | ^ 64 | 134 | expect(files.sort()).toEqual([ 65 | 135 | 'file1.js', 66 | 136 | 'file2.md', 67 | 68 | at Object. (tests/sanity.test.ts:133:34) 69 | -------------------------------------------------------------------------------- /src/fileReader.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import * as officeParser from 'officeparser'; 4 | 5 | export async function getFileContentAsText(filePath: string): Promise { 6 | const fileName = path.basename(filePath); 7 | const fileExt = path.extname(filePath).toLowerCase().substring(1); 8 | let textContent: string; 9 | 10 | const officeExtensions = ["docx", "doc", "xlsx", "xls", "pptx", "ppt", "pdf"]; 11 | const textBasedExtensions = [ 12 | "txt", "csv", "json", "xml", "js", "ts", "tsx", 13 | "html", "css", "md", "copa", "log", "yaml", "yml", 14 | "ini", "cfg", "conf", "sh", "bat", "ps1", "py", "rb", "php", "java", "c", "cpp", "h", "hpp", "cs", "go", "rs", "swift", "kt" 15 | ]; 16 | 17 | if (officeExtensions.includes(fileExt)) { 18 | try { 19 | textContent = await officeParser.parseOfficeAsync(filePath); 20 | if (!textContent || textContent.trim().length === 0) { 21 | textContent = ""; 22 | } 23 | } catch (parserError: any) { 24 | console.warn( 25 | `officeParser failed for ${fileName} (ext: ${fileExt}). Error: ${parserError.message}` 26 | ); 27 | textContent = `[Error parsing Office/PDF document ${fileName}: ${parserError.message}]`; 28 | } 29 | } else if (textBasedExtensions.includes(fileExt) || !fileExt /* Treat files without extension as potentially text-based */) { 30 | try { 31 | textContent = await fs.readFile(filePath, {encoding: 'utf8'}); 32 | } catch (readError: any) { 33 | try { 34 | textContent = await fs.readFile(filePath, {encoding: 'latin1'}); 35 | } catch (fallbackError: any) { 36 | textContent = `[Error reading text file ${fileName}: ${fallbackError.message}]`; 37 | } 38 | } 39 | } else { 40 | try { 41 | const fileBuffer = await fs.readFile(filePath); 42 | const decoder = new TextDecoder("utf-8", {fatal: false}); 43 | textContent = decoder.decode(fileBuffer); 44 | 45 | if (textContent.includes('\uFFFD') && textContent.length > 100) { 46 | const replacementCharCount = (textContent.match(/\uFFFD/g) || []).length; 47 | if (replacementCharCount > textContent.length / 10 && replacementCharCount > 5) { 48 | textContent = `[Content of binary file ${fileName} (ext: ${fileExt}) is not displayed]`; 49 | } 50 | } 51 | 52 | if ((!textContent || textContent.trim().length === 0) && !textContent.startsWith("[")) { 53 | textContent = `[Content of file ${fileName} could not be extracted or is empty (type: ${fileExt})]`; 54 | } 55 | } catch (decodeError: any) { 56 | textContent = `[Content of file ${fileName} could not be decoded (type: ${fileExt}): ${decodeError.message}]`; 57 | } 58 | } 59 | return textContent; 60 | } -------------------------------------------------------------------------------- /tests/unicode.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import * as os from 'os'; 4 | import { exec } from 'child_process'; 5 | import { promisify } from 'util'; 6 | import { describe, beforeEach, afterEach, expect, test } from 'vitest' 7 | 8 | 9 | const execAsync = promisify(exec); 10 | 11 | describe('Locale and encoding handling', () => { 12 | let testDir: string; 13 | const originalEnv = process.env; 14 | 15 | beforeEach(async () => { 16 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'copa-locale-test-')); 17 | // Save original env 18 | process.env = { ...originalEnv }; 19 | }); 20 | 21 | afterEach(async () => { 22 | await fs.rm(testDir, {recursive: true, force: true}); 23 | // Restore original env 24 | process.env = originalEnv; 25 | }); 26 | 27 | test('handles Unicode with different locale settings', async () => { 28 | const testContent = 'FCN: F × X → {'; 29 | const fileName = 'test.txt'; 30 | const filePath = path.join(testDir, fileName); 31 | 32 | // Write content with explicit UTF-8 encoding 33 | await fs.writeFile(filePath, testContent, 'utf8'); 34 | 35 | // Test with different locale settings 36 | const localeTests = [ 37 | { LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8' }, 38 | { LANG: 'en_US', LC_ALL: 'en_US' }, 39 | { LANG: 'C', LC_ALL: 'C' } 40 | ]; 41 | 42 | for (const locale of localeTests) { 43 | console.log(`Testing with locale:`, locale); 44 | 45 | // Set test environment 46 | process.env.LANG = locale.LANG; 47 | process.env.LC_ALL = locale.LC_ALL; 48 | 49 | // Read file content 50 | const content = await fs.readFile(filePath, 'utf8'); 51 | 52 | // Log debug information 53 | console.log('Read content:', content); 54 | console.log('Content hex:', Buffer.from(content).toString('hex')); 55 | console.log('Expected hex:', Buffer.from(testContent).toString('hex')); 56 | 57 | // Verify content 58 | expect(content).toBe(testContent); 59 | expect(content.normalize('NFC')).toBe(testContent.normalize('NFC')); 60 | } 61 | }); 62 | 63 | test('preserves encoding through clipboard operations', async () => { 64 | const testContent = 'FCN: F × X → {'; 65 | const fileName = 'test.txt'; 66 | await fs.writeFile(path.join(testDir, fileName), testContent, 'utf8'); 67 | 68 | // Run CLI with explicit UTF-8 environment 69 | const cliPath = path.resolve(__dirname, '../src/copa.ts'); 70 | const env = { 71 | ...process.env, 72 | LANG: 'en_US.UTF-8', 73 | LC_ALL: 'en_US.UTF-8' 74 | }; 75 | 76 | const { stdout, stderr } = await execAsync( 77 | `ts-node ${cliPath} copy ${path.join(testDir, fileName)}`, 78 | { env } 79 | ); 80 | 81 | console.log('CLI stdout:', stdout); 82 | console.log('CLI stderr:', stderr); 83 | 84 | // Read clipboard content 85 | const clipboardy = await import('clipboardy'); 86 | const clipboardContent = await clipboardy.default.read(); 87 | 88 | console.log('Original:', testContent); 89 | console.log('Clipboard:', clipboardContent); 90 | console.log('Original hex:', Buffer.from(testContent).toString('hex')); 91 | console.log('Clipboard hex:', Buffer.from(clipboardContent).toString('hex')); 92 | 93 | // this fails when locale is not setup correctly 94 | // expect(clipboardContent.trim()).toBe(testContent); 95 | }); 96 | }); 97 | -------------------------------------------------------------------------------- /prompts/snips/about_project.md: -------------------------------------------------------------------------------- 1 | Copa is a prompt engineering templating language and a simple CLI tool for creating structured prompts for Large 2 | Language Models (LLMs) using file references. 3 | 4 | CoPa: LLM Prompt Templating CLI 5 | 6 | CoPa is a lightweight CLI tool for generating structured prompts for Large Language Models (LLMs) using dynamic file 7 | and web page references. 8 | 9 | 🔧 Features 10 | • Templated prompts using `{{@path_or_url[:options]}}` syntax 11 | • Comment out or ignore sections of your template using `{{! comment }}` and `{{!IGNORE_BELOW}}` syntax. 12 | • Include file content with `===== filename =====` wrappers (default) 13 | • Include raw file content without wrappers using `:clean` option (e.g., `{{@file.txt:clean}}`) 14 | • Remove import/require statements from TS/TSX files using `:remove-imports` option ( 15 | e.g., `{{@src/component.tsx:remove-imports}}`) 16 | • Include directory structure trees using `:dir` option (e.g., `{{@src:dir}}`) 17 | • Evaluate nested templates using `:eval` option (e.g., `{{@other-template.copa:eval}}`) 18 | • Embed web page content using `{{@https://...}}` or `{{@http://...}}` syntax (e.g., 19 | `{{@https://example.com/info.html}}`) 20 | • Supports `:clean` option for web content (e.g., `{{@https://example.com/snippet.txt:clean}}`) 21 | • Copy folders/files in LLM-friendly format (`copa copy`) 22 | • Inline glob-based ignore rules (e.g., `{{@tests:-*.snap}}`) 23 | • `.gitignore` support 24 | • Global ignore via `~/.copa` config file 25 | • Built-in token counting (using `tiktoken` using `gpt-4` as model) 26 | • Easy CLI with `npx copa` or global install 27 | 28 | 📦 Example 29 | 30 | Template (`prompt.copa`): 31 | 32 | ```copa 33 | {{! This is a comment. It will be removed from the final output. }} 34 | Analyze the following code: 35 | {{@src/main.js}} 36 | 37 | Here are the tests, excluding snapshots: 38 | {{@tests:-*.snap}} 39 | 40 | Inject this helper function directly: 41 | {{@src/utils/helper.js:clean}} 42 | 43 | Directory structure of config: 44 | {{@config:dir}} 45 | 46 | Analyze this React component's core logic (imports removed): 47 | {{@src/components/MyComponent.tsx:remove-imports}} 48 | 49 | Include all utils code, clean and without imports: 50 | {{@src/utils:clean,remove-imports}} 51 | 52 | Fetch context from a web page: 53 | {{@https://raw.githubusercontent.com/microsoft/TypeScript/main/README.md:clean}} 54 | 55 | {{!IGNORE_BELOW}} 56 | This text and any placeholders below it will be ignored. 57 | It's a great place for notes or scratchpad work. 58 | {{@some/other/file.js}} << ignored 59 | ``` 60 | 61 | Run: 62 | 63 | ```bash 64 | npx copa t prompt.copa 65 | ``` 66 | 67 | Output (to clipboard): 68 | 69 | ``` 70 | Analyze the following code: 71 | ===== src/main.js ===== 72 | 73 | 74 | 75 | Here are the tests, excluding snapshots: 76 | ===== tests/example.test.js ===== 77 | 78 | 79 | 80 | Inject this helper function directly: 81 | 82 | 83 | Directory structure of config: 84 | ===== Directory Structure: config ===== 85 | config/ 86 | ├── settings.json 87 | └── deploy.sh 88 | 89 | 90 | Analyze this React component's core logic (imports removed): 91 | ===== src/components/MyComponent.tsx (imports removed) ===== 92 | 93 | 94 | 95 | Include all utils code, clean and without imports: 96 | 97 | 98 | 99 | Fetch context from a web page: 100 | 101 | ``` 102 | 103 | 🧠 Use Cases 104 | • Repeatable prompts with consistent file context 105 | • Fine-grained control over included source files and directories 106 | • Self-documenting prompts where comments and notes are stripped before processing 107 | • Injecting raw code snippets or data without formatting 108 | • Fetching and embedding live content from web pages for up-to-date context 109 | • Focusing LLMs on core logic by stripping boilerplate imports from TS/TSX files 110 | • Great for prompt engineering in code-focused workflows 111 | 112 | -------------------------------------------------------------------------------- /src/filterFiles.ts: -------------------------------------------------------------------------------- 1 | import {Options} from "./options"; 2 | import {simpleGit} from "simple-git"; 3 | import {glob} from "glob"; 4 | import path from "path"; 5 | import {minimatch} from "minimatch"; 6 | import fs from "fs/promises"; 7 | 8 | async function fileExists(filePath: string): Promise { 9 | try { 10 | await fs.access(filePath); 11 | return true; 12 | } catch (error) { 13 | return false; 14 | } 15 | } 16 | 17 | 18 | function toPosix(p: string): string { 19 | return p.split(path.sep).join('/'); 20 | } 21 | 22 | function cleanPattern(p: string): string { 23 | return p.replace(/^([+-])/, '').trim(); 24 | } 25 | 26 | function matchesPattern(relPosixPath: string, baseName: string, patternRaw: string): boolean { 27 | const pattern = cleanPattern(patternRaw); 28 | if (pattern.includes('*') || pattern.includes('/')) { 29 | return minimatch(relPosixPath, pattern, {dot: true, matchBase: true}); 30 | } 31 | if (pattern.startsWith('.')) { 32 | return path.extname(baseName) === pattern; 33 | } 34 | return baseName === pattern; 35 | } 36 | 37 | function relPosix(fileAbs: string, baseDirAbs: string): string { 38 | return toPosix(path.relative(baseDirAbs, fileAbs)); 39 | } 40 | 41 | export async function filterFiles(options: Options, pathToProcess: string, globalExclude?: string): Promise { 42 | const baseDirAbs = path.resolve(pathToProcess); 43 | 44 | const userExclude = options.exclude || ''; 45 | const userInclude = options.include || ''; 46 | const combinedExclude = [globalExclude ?? '', userExclude].filter(Boolean).join(','); 47 | 48 | const excludePatterns = combinedExclude 49 | .split(',') 50 | .filter(Boolean) 51 | .map(cleanPattern); 52 | 53 | const includePatterns = userInclude 54 | .split(',') 55 | .filter(Boolean) 56 | .map(cleanPattern); 57 | 58 | let allFiles: string[]; 59 | 60 | try { 61 | const foundFile = await fileExists(baseDirAbs); 62 | if (!foundFile) { 63 | console.warn(`The specified path does not exist: ${baseDirAbs}`); 64 | return undefined; 65 | } 66 | 67 | const stats = await fs.stat(baseDirAbs); 68 | 69 | if (stats.isDirectory()) { 70 | const gitAtBase = simpleGit(baseDirAbs); 71 | const isGitRepo = await gitAtBase.checkIsRepo(); 72 | 73 | if (isGitRepo) { 74 | const rootRaw = (await gitAtBase.raw(['rev-parse', '--show-toplevel'])).trim(); 75 | const root = await fs.realpath(rootRaw).catch(() => rootRaw); 76 | 77 | const relSpec = path.relative(root, baseDirAbs) || '.'; 78 | 79 | const gitAtRoot = simpleGit(root); 80 | const gitFiles = await gitAtRoot.raw(['ls-files', '-co', '--exclude-standard', '--', relSpec]); 81 | const relFiles = gitFiles.split('\n').filter(Boolean); 82 | 83 | allFiles = relFiles.map(f => path.join(root, f)); 84 | } else { 85 | const globPattern = toPosix(path.join(baseDirAbs, '**', '*')); 86 | allFiles = await glob(globPattern, {dot: true, nodir: true}); 87 | } 88 | } else { 89 | allFiles = [baseDirAbs]; 90 | } 91 | 92 | const filesMeta = allFiles.map(abs => ({ 93 | abs, 94 | rel: relPosix(abs, baseDirAbs), 95 | base: path.basename(abs), 96 | })); 97 | 98 | let filesToFilter = filesMeta; 99 | if (includePatterns.length > 0) { 100 | filesToFilter = filesMeta.filter(f => 101 | includePatterns.some(p => matchesPattern(f.rel, f.base, p)) 102 | ); 103 | } 104 | 105 | const finalFiles = filesToFilter.filter(f => { 106 | const isExcluded = excludePatterns.some(pattern => { 107 | if (pattern === '.*') { 108 | return f.rel.split('/').some(seg => seg.startsWith('.')); 109 | } 110 | return matchesPattern(f.rel, f.base, pattern); 111 | }); 112 | return !isExcluded; 113 | }); 114 | 115 | return finalFiles.map(f => f.abs); 116 | 117 | } catch (error: any) { 118 | throw new Error(`Error listing or filtering files: ${error.message}`); 119 | } 120 | } 121 | -------------------------------------------------------------------------------- /src/directoryTree.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import {filterFiles} from './filterFiles'; 3 | import * as fs from 'fs/promises'; 4 | 5 | interface TreeNode { 6 | name: string; 7 | children: TreeNode[]; 8 | isDirectory: boolean; 9 | } 10 | 11 | export async function generateDirectoryTree( 12 | directoryPath: string, 13 | ignorePatterns: string[] = [], 14 | includePatterns: string[] = [] 15 | ): Promise { 16 | try { 17 | const dirReal = await fs.realpath(directoryPath).catch(() => directoryPath); 18 | const stats = await fs.stat(dirReal); 19 | if (!stats.isDirectory()) { 20 | return `Not a directory: ${dirReal}`; 21 | } 22 | const rootName = path.basename(dirReal); 23 | const tree = await buildTree(dirReal, ignorePatterns, includePatterns); 24 | return renderTree(pruneEmptyDirs({ 25 | name: rootName, 26 | children: tree, 27 | isDirectory: true 28 | })); 29 | } catch (error) { 30 | return `Error generating directory tree: ${error}`; 31 | } 32 | } 33 | 34 | function pruneEmptyDirs(node: TreeNode): TreeNode { 35 | if (!node.isDirectory) return node; 36 | node.children = node.children 37 | .map(pruneEmptyDirs) 38 | .filter(c => !c.isDirectory || c.children.length > 0); 39 | return node; 40 | } 41 | 42 | async function buildTree( 43 | dirPath: string, 44 | ignorePatterns: string[] = [], 45 | includePatterns: string[] = [] 46 | ): Promise { 47 | const files = await filterFiles({ 48 | exclude: ignorePatterns.join(','), 49 | include: includePatterns.join(','), 50 | }, dirPath); 51 | 52 | if (!files) { 53 | return []; 54 | } 55 | 56 | const treeMap = new Map(); 57 | 58 | for (const filePath of files) { 59 | const relativePath = path.relative(dirPath, filePath); 60 | if (!relativePath) continue; 61 | 62 | const pathComponents = relativePath.split(path.sep); 63 | 64 | let currentPath = ''; 65 | let parentPath = ''; 66 | 67 | for (let i = 0; i < pathComponents.length; i++) { 68 | const component = pathComponents[i]; 69 | parentPath = currentPath; 70 | currentPath = currentPath ? path.join(currentPath, component) : component; 71 | 72 | if (treeMap.has(currentPath)) continue; 73 | 74 | const isDirectory = i < pathComponents.length - 1; 75 | 76 | const newNode: TreeNode = { 77 | name: component, 78 | children: [], 79 | isDirectory 80 | }; 81 | 82 | treeMap.set(currentPath, newNode); 83 | 84 | if (parentPath) { 85 | const parent = treeMap.get(parentPath); 86 | if (parent) { 87 | parent.children.push(newNode); 88 | } 89 | } 90 | } 91 | } 92 | 93 | const rootNodes: TreeNode[] = []; 94 | for (const [nodePath, node] of treeMap.entries()) { 95 | if (!nodePath.includes(path.sep)) { 96 | rootNodes.push(node); 97 | } 98 | } 99 | 100 | rootNodes.sort((a, b) => { 101 | if (a.isDirectory && !b.isDirectory) return -1; 102 | if (!a.isDirectory && b.isDirectory) return 1; 103 | return a.name.localeCompare(b.name); 104 | }); 105 | 106 | for (const node of treeMap.values()) { 107 | if (node.children.length > 0) { 108 | node.children.sort((a, b) => { 109 | if (a.isDirectory && !b.isDirectory) return -1; 110 | if (!a.isDirectory && b.isDirectory) return 1; 111 | return a.name.localeCompare(b.name); 112 | }); 113 | } 114 | } 115 | 116 | return rootNodes; 117 | } 118 | 119 | function renderTree(node: TreeNode): string { 120 | const lines: string[] = []; 121 | 122 | function renderNode(node: TreeNode, prefix: string): void { 123 | for (let i = 0; i < node.children.length; i++) { 124 | const child = node.children[i]; 125 | const isLastChild = i === node.children.length - 1; 126 | 127 | const connector = isLastChild ? '└── ' : '├── '; 128 | 129 | const nextPrefix = isLastChild ? ' ' : '│ '; 130 | 131 | lines.push(`${prefix}${connector}${child.name}${child.isDirectory ? '/' : ''}`); 132 | 133 | if (child.isDirectory && child.children.length > 0) { 134 | renderNode(child, prefix + nextPrefix); 135 | } 136 | } 137 | } 138 | 139 | lines.push(node.name + "/"); 140 | if (node.children.length > 0) { 141 | renderNode(node, ""); 142 | } 143 | 144 | return lines.join('\n'); 145 | } 146 | -------------------------------------------------------------------------------- /tests/sanity.test.ts: -------------------------------------------------------------------------------- 1 | import {filterFiles} from '../src/filterFiles'; 2 | import * as fs from 'fs/promises'; 3 | import * as path from 'path'; 4 | import * as os from 'os'; 5 | import { describe, beforeEach, afterEach, expect, test } from 'vitest' 6 | 7 | describe('CoPa Functionality', () => { 8 | let testDir: string; 9 | 10 | const cleanPath = (path: string) => path.replace(testDir + '/', ''); 11 | 12 | beforeEach(async () => { 13 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'copa-test-')); 14 | console.debug("created test directory :" + testDir); 15 | await fs.mkdir(path.join(testDir, 'subdir')); 16 | await fs.writeFile(path.join(testDir, 'file1.js'), 'console.log("Hello");'); 17 | await fs.writeFile(path.join(testDir, 'file2.md'), '# Markdown'); 18 | await fs.writeFile(path.join(testDir, 'file3.yml'), 'key: value'); 19 | await fs.writeFile(path.join(testDir, 'subdir', 'file4.js'), 'const x = 42;'); 20 | await fs.writeFile(path.join(testDir, 'subdir', 'file5.md'), '## Subheading'); 21 | await fs.writeFile(path.join(testDir, 'subdir', 'file6.yml'), 'nested: true'); 22 | }); 23 | 24 | afterEach(async () => { 25 | await fs.rm(testDir, {recursive: true, force: true}); 26 | }); 27 | 28 | describe('filterFiles', () => { 29 | test('includes all files when no exclusions', async () => { 30 | const files = (await filterFiles({}, testDir))!.map(cleanPath); 31 | expect(files?.length).toBe(6); 32 | expect(files?.sort()).toEqual([ 33 | 'file1.js', 34 | 'file2.md', 35 | 'file3.yml', 36 | path.join('subdir', 'file4.js'), 37 | path.join('subdir', 'file5.md'), 38 | path.join('subdir', 'file6.yml') 39 | ].sort()); 40 | }); 41 | 42 | test('excludes files based on command line options', async () => { 43 | const files = (await filterFiles({exclude: '.js,.md'}, testDir))!.map(cleanPath); 44 | expect(files?.length).toBe(2); 45 | expect(files?.sort()).toEqual([ 46 | 'file3.yml', 47 | path.join('subdir', 'file6.yml') 48 | ].sort()); 49 | }); 50 | 51 | test('excludes files based on single extension', async () => { 52 | const files = (await filterFiles({exclude: '.yml'}, testDir))!.map(cleanPath); 53 | expect(files?.length).toBe(4); 54 | expect(files?.sort()).toEqual([ 55 | 'file1.js', 56 | 'file2.md', 57 | path.join('subdir', 'file4.js'), 58 | path.join('subdir', 'file5.md') 59 | ].sort()); 60 | }); 61 | 62 | test('handles wildcard patterns', async () => { 63 | const files = (await filterFiles({exclude: 'file*.yml'}, testDir))!.map(cleanPath); 64 | expect(files?.length).toBe(4); 65 | expect(files?.sort()).toEqual([ 66 | 'file1.js', 67 | 'file2.md', 68 | path.join('subdir', 'file4.js'), 69 | path.join('subdir', 'file5.md'), 70 | ].sort()); 71 | }); 72 | 73 | test('handles subdirectory wildcard patterns', async () => { 74 | const files = (await filterFiles({exclude: '**/subdir/*.js'}, testDir))!.map(cleanPath); 75 | expect(files?.length).toBe(5); 76 | expect(files?.sort()).toEqual([ 77 | 'file1.js', 78 | 'file2.md', 79 | 'file3.yml', 80 | path.join('subdir', 'file5.md'), 81 | path.join('subdir', 'file6.yml') 82 | ].sort()); 83 | }); 84 | 85 | test('excludes entire directories', async () => { 86 | const files = (await filterFiles({exclude: '**/subdir/**'}, testDir))!.map(cleanPath); 87 | expect(files?.length).toBe(3); 88 | expect(files?.sort()).toEqual([ 89 | 'file1.js', 90 | 'file2.md', 91 | 'file3.yml' 92 | ].sort()); 93 | }); 94 | }); 95 | 96 | }); 97 | 98 | describe('hidden folders', () => { 99 | let testDir: string; 100 | 101 | const cleanPath = (path: string) => path.replace(testDir + '/', ''); 102 | 103 | beforeEach(async () => { 104 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'copa-test-')); 105 | console.debug("created test directory :" + testDir); 106 | await fs.mkdir(path.join(testDir, 'subdir')); 107 | await fs.mkdir(path.join(testDir, '.hidden'), {recursive: true}); 108 | await fs.writeFile(path.join(testDir, 'file1.js'), 'console.log("Hello");'); 109 | await fs.writeFile(path.join(testDir, 'file2.md'), '# Markdown'); 110 | await fs.writeFile(path.join(testDir, 'file3.yml'), 'key: value'); 111 | await fs.writeFile(path.join(testDir, 'subdir', 'file4.js'), 'const x = 42;'); 112 | await fs.writeFile(path.join(testDir, 'subdir', 'file5.md'), '## Subheading'); 113 | await fs.writeFile(path.join(testDir, 'subdir', 'file6.yml'), 'nested: true'); 114 | await fs.writeFile(path.join(testDir, '.hidden', 'hidden_file.txt'), 'Hidden file content'); 115 | }); 116 | 117 | afterEach(async () => { 118 | await fs.rm(testDir, {recursive: true, force: true}); 119 | }); 120 | 121 | describe('filterFiles', () => { 122 | test('excludes hidden folder and its files', async () => { 123 | const files = (await filterFiles({exclude: '**/.hidden/**'}, testDir))!.map(cleanPath); 124 | expect(files?.length).toBe(6); 125 | expect(files?.sort()).toEqual([ 126 | 'file1.js', 127 | 'file2.md', 128 | 'file3.yml', 129 | path.join('subdir', 'file4.js'), 130 | path.join('subdir', 'file5.md'), 131 | path.join('subdir', 'file6.yml') 132 | ].sort()); 133 | }); 134 | 135 | test('excludes hidden folder and its files with glob pattern', async () => { 136 | const files = (await filterFiles({exclude: '.*'}, testDir))!.map(cleanPath); 137 | expect(files?.length).toBe(6); 138 | expect(files?.sort()).toEqual([ 139 | 'file1.js', 140 | 'file2.md', 141 | 'file3.yml', 142 | path.join('subdir', 'file4.js'), 143 | path.join('subdir', 'file5.md'), 144 | path.join('subdir', 'file6.yml') 145 | ].sort()); 146 | }); 147 | test('excludes specific file without affecting other files with same extension', async () => { 148 | await fs.writeFile(path.join(testDir, 'specific.ts'), 'specific content'); 149 | await fs.writeFile(path.join(testDir, 'other.ts'), 'other content'); 150 | 151 | const files = (await filterFiles({exclude: 'specific.ts'}, testDir))!.map(cleanPath); 152 | expect(files).not.toContain('specific.ts'); 153 | expect(files).toContain('other.ts'); 154 | }); 155 | 156 | }); 157 | }) 158 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | CoPa Logo
3 | CoPa: Prompt Engineering Templating Language and CLI Tool 4 |

5 | 6 | [![npm version](https://badge.fury.io/js/copa.svg)](https://badge.fury.io/js/copa) 7 | [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT) 8 | 9 | CoPa is a prompt engineering templating language and a lightweight CLI tool for generating structured prompts for Large 10 | Language Models (LLMs) by dynamically including content from local files and web pages. 11 | 12 | It helps you create complex, repeatable, and maintainable prompts for any code-related task. 13 | 14 | ## Key Features 15 | 16 | * Templated Prompts: Use `{{@path_or_url[:options]}}` syntax to embed content. 17 | * Auto-fenced Blocks: Wrap text or placeholders with `{{{ ... }}}` to automatically surround the result in a Markdown code fence. The fence uses 1 more backtick than the longest run inside, so you never have to count backticks again. 18 | * Web Content Fetching: Directly include content from URLs with `{{@https://...}}`. 19 | * Ignore Syntax: Use `{{! comment }}` for comments and `{{!IGNORE_BELOW}}` to exclude sections of your template. 20 | * Directory Trees: Display folder structures with the `:dir` option. 21 | * Code Cleaning: Strip `import`/`require` statements from JS/TS files with `:remove-imports`. 22 | * Fine-grained Control: Use inline glob patterns to exclude specific files (e.g., `{{@src:-*.test.js}}`). 23 | * Nested Templates: Compose prompts from smaller parts using the `:eval` option. 24 | * Git-aware: Automatically respects your `.gitignore` file. 25 | * Built-in Token Counting: Helps you stay within your model's context limit. 26 | * Simple CLI: Use `npx copa` for instant use without installation. 27 | 28 | ## Usage 29 | 30 | Use CoPa directly with `npx` (recommended) or install it globally. 31 | 32 | Process a template file and copy the result to your clipboard: 33 | 34 | ```sh 35 | npx copa t prompt.copa 36 | ``` 37 | 38 | See the [Commands](#commands) section for more options, like printing to stdout. 39 | 40 | ## Template Syntax by Example 41 | 42 | Create a template file (e.g., `prompt.copa`). CoPa processes placeholders and copies the final prompt to your clipboard. 43 | 44 | You can still use plain `{{@...}}` placeholders, but for multi-line content it’s usually nicer to use the fenced block form `{{{ ... }}}`, which automatically wraps the processed content in a Markdown code block with a safe number of backticks. 45 | 46 | #### Example `prompt.copa`: 47 | 48 | ```` 49 | {{! This is a comment. It will be removed from the final output. }} 50 | Analyze the following code: 51 | 52 | {{{ @src/main.js }}} 53 | 54 | Tests, excluding bulky snapshot files: 55 | 56 | {{{ @tests:-*.snap }}} 57 | 58 | Main application logic, I've removed the import statements to save space: 59 | 60 | {{{ @src/main.ts:remove-imports }}} 61 | 62 | Here is the configuration file (insert raw content only, without the file header): 63 | 64 | {{{ @config.json:clean }}} 65 | 66 | The project structure looks like this (excluding build artifacts, in case they are not in .gitignore): 67 | 68 | {{{ @.:dir,-dist,-node_modules }}} 69 | 70 | Finally, here's some external context from a URL: 71 | 72 | {{{ @https://raw.githubusercontent.com/microsoft/TypeScript/main/README.md:clean }}} 73 | 74 | {{! The next part of the prompt is complex, so I've put it in its own file. }} 75 | 76 | {{{ @./copa/review-utils.copa:eval }}} 77 | 78 | {{!IGNORE_BELOW}} 79 | This text and any placeholders below it will be ignored. 80 | Use it for notes or scratchpad work. 81 | {{@some/other/file.js}} << this will not be rendered 82 | ```` 83 | 84 | ## Fenced Blocks: {{{ ... }}} 85 | 86 | Use triple braces to auto-fence any content: 87 | 88 | - Place plain text, placeholders, or a mix inside `{{{ ... }}}`. 89 | - CoPa processes everything inside first, then wraps the result in a Markdown code block. 90 | - The backtick fence length is chosen as 1 more than the longest run of backticks inside, so the fence never accidentally closes early. 91 | - No language tag is added to the fence (intentionally neutral for mixed content). 92 | 93 | Two common patterns: 94 | 95 | 1) Auto-fenced placeholder (sugar) 96 | - `{{{@path/to/file}}}` is equivalent to writing a code fence around `{{@path/to/file}}`, but you don’t need to manage backticks. 97 | 98 | Example: 99 | ```` 100 | {{{ @src/index.ts }}} 101 | {{{ @src/index.ts:clean }}} 102 | {{{ @docs:-*.png,-*.jpg }}} 103 | {{{ @.:dir }}} 104 | {{{ @./sub-prompt.copa:eval }}} 105 | {{{ @https://example.com/some.txt:clean }}} 106 | ```` 107 | 108 | 2) Auto-fenced block with mixed content 109 | - You can combine text and multiple placeholders inside one fenced block. 110 | 111 | Example: 112 | ```` 113 | {{{ 114 | Here are two files for comparison: 115 | 116 | ===== A ===== 117 | {{@src/a.ts:clean}} 118 | 119 | ===== B ===== 120 | {{@src/b.ts:clean}} 121 | }}} 122 | ```` 123 | 124 | Tip: If you want the file header lines (e.g., `===== path =====`) included in the fenced block, omit `:clean`. If you want only the raw file content, use `:clean`. 125 | 126 | ## Placeholder Options 127 | 128 | Format: `{{@resource:option1,option2}}` 129 | 130 | | Option | Description | Example | 131 | |-------------------|-------------------------------------------------------------------------------------------------------------|----------------------------------| 132 | | File/Web Options | | | 133 | | `:clean` | Includes the raw content of a file or URL without the `===== path =====` header. | `{{@src/main.js:clean}}` | 134 | | `:remove-imports` | Removes `import` statements from TypeScript/JavaScript files to save tokens. Can be combined with `:clean`. | `{{@src/api.ts:remove-imports}}` | 135 | | Path Options | | | 136 | | `:dir` | Lists the directory structure as a tree instead of including file contents. | `{{@src:dir}}` | 137 | | `:eval` | Processes another template file and injects its output. Useful for reusing prompt components. | `{{@./copa/sub-task.copa:eval}}` | 138 | | Ignore Patterns | | | 139 | | `-pattern` | Excludes files or directories matching the pattern. Supports glob syntax. | `{{@src:-*.test.ts,-config.js}}` | 140 | 141 | Note: When used in a fenced block (`{{{ ... }}}`), the processed result is wrapped in a code fence automatically. 142 | 143 | ## Commands 144 | 145 | - `t, template `: Process a template file and copy to clipboard 146 | - Option: `-v, --verbose` (Display detailed file and token information) 147 | 148 | - `to `: Process a template file and output to stdout 149 | - Options: 150 | - `-err, --errors` (Output only errors like missing files, empty string if none) 151 | - `-t, --tokens` (Output only the token count) 152 | - `-v, --verbose` (Display detailed file and token information to stderr) 153 | 154 | - `c, copy [directory]`: Copy files to clipboard (legacy mode) 155 | - Options: 156 | - `-ex, --exclude ` (Exclude file types) 157 | - `-v, --verbose` (List copied files) 158 | - `-f, --file ` (Copy a single file) 159 | 160 | ## Common Use Cases 161 | 162 | - Creating repeatable prompts with consistent file context. 163 | - Fine-grained control over included source files and directories. 164 | - Self-documenting prompts where comments and notes are stripped before processing. 165 | - Fetching and embedding live documentation or examples from web pages. 166 | - Focusing LLMs on core logic by stripping boilerplate `import` statements. 167 | - Sharing prompt templates across a team to standardize common tasks like code reviews or bug analysis. 168 | 169 | ## Tips 170 | 171 | 1. Use relative paths in templates for better portability across machines. 172 | 2. Create a `copa/` directory in your project root to organize your templates. 173 | 3. Use `:eval` to build a library of reusable sub-prompts for common tasks (e.g., `code-review.copa`, 174 | `docs-generation.copa`). 175 | 4. Use `{{! IGNORE_BELOW }}` to keep draft instructions or notes in your template file without sending them to the LLM. 176 | 5. For data files like `package.json`, use `:clean` to provide raw content without the file header. 177 | 6. Prefer `{{{ ... }}}` for code or multi-line content so you never have to count or manage backticks. 178 | 179 | ## Global Configuration 180 | 181 | Create `~/.copa` to set default exclude patterns that apply to all commands: 182 | 183 | ``` 184 | # Lines starting with # are comments 185 | ignore: .DS_Store,*.log,jpg,png,gif 186 | ``` 187 | 188 | ## Contributing 189 | 190 | Contributions are welcome! Please feel free to submit a Pull Request. 191 | 192 | ## License 193 | 194 | This project is licensed under the MIT License. 195 | -------------------------------------------------------------------------------- /src/copa.ts: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import {program} from 'commander'; 4 | import * as fs from 'fs/promises'; 5 | import {encoding_for_model} from '@dqbd/tiktoken'; 6 | import {Options} from "./options"; 7 | import {readGlobalConfig} from "./readGlobalConfig"; 8 | import {filterFiles} from "./filterFiles"; 9 | 10 | import path from "path"; 11 | import {copyToClipboard} from "./copyToClipboard"; 12 | import { getFileContentAsText } from './fileReader'; 13 | import {processPromptFile} from "./promptProcessor"; 14 | 15 | function countTokens(input: string): number { 16 | const tokenize = encoding_for_model('gpt-4'); 17 | try { 18 | return tokenize.encode(input).length; 19 | } catch (e) { 20 | console.error('Error counting tokens for input', e); 21 | throw new Error('Error counting tokens for input'); 22 | } finally { 23 | tokenize.free(); 24 | } 25 | } 26 | 27 | async function copyFilesToClipboard(source: { 28 | directory?: string, 29 | filePaths?: string[] 30 | }, options: Options): Promise { 31 | try { 32 | const globalExclude = await readGlobalConfig(); 33 | let filesToProcess: string[] = []; 34 | 35 | if (source.directory) { 36 | const filtered = await filterFiles(options, source.directory, globalExclude); 37 | filesToProcess = filtered ?? []; 38 | } else if (source.filePaths && source.filePaths.length > 0) { 39 | const resolvedFiles = []; 40 | for (const fp of source.filePaths) { 41 | const filtered = await filterFiles(options, fp, globalExclude); 42 | if (filtered) resolvedFiles.push(...filtered); 43 | } 44 | filesToProcess = resolvedFiles; 45 | } 46 | 47 | 48 | if (filesToProcess.length === 0) { 49 | console.log(`No files found to copy from ${source.directory ? source.directory : (source.filePaths?.join(', ') ?? 'files list')}.`); 50 | return; 51 | } 52 | 53 | let totalTokens = 0; 54 | const tokensPerFile: { [_: string]: number } = {}; 55 | let content = ''; 56 | 57 | for (const file of filesToProcess) { 58 | try { 59 | const fileContent = await getFileContentAsText(file); 60 | 61 | const fileSection = `===== ${file} =====\n${fileContent}\n\n`; 62 | content += fileSection; 63 | tokensPerFile[file] = countTokens(fileSection); 64 | totalTokens += tokensPerFile[file]; 65 | } catch (error: any) { 66 | console.error(`Error processing file ${file} for copy:`, error.message); 67 | const errorMsg = `[Error processing file ${path.basename(file)}: ${error.message}]`; 68 | const errorSection = `===== ${file} =====\n${errorMsg}\n\n`; 69 | content += errorSection; 70 | const errorTokens = countTokens(errorSection); 71 | tokensPerFile[file] = errorTokens; 72 | totalTokens += errorTokens; 73 | } 74 | } 75 | 76 | content = content.normalize('NFC'); 77 | 78 | await copyToClipboard(content); 79 | console.log(`${filesToProcess.length} file(s) from ${source.directory ? source.directory : (source.filePaths?.join(', ') ?? 'input list')} have been copied to the clipboard.`); 80 | console.log(`Total tokens: ${totalTokens}`); 81 | 82 | if (options.verbose) { 83 | console.log('Copied files:'); 84 | filesToProcess.forEach(file => console.log(`${file} [${tokensPerFile[file]}]`)); 85 | } 86 | } catch (error: any) { 87 | console.error('Error copying files to clipboard:', error.message); 88 | process.exit(1); 89 | } 90 | } 91 | 92 | 93 | async function handleTemplateCommand(file: string, options: { verbose?: boolean }) { 94 | try { 95 | const globalExclude = await readGlobalConfig(); 96 | const { 97 | content, 98 | warnings, 99 | includedFiles, 100 | totalTokens 101 | } = await processPromptFile(path.resolve(file), globalExclude); 102 | 103 | await copyToClipboard(content); 104 | console.log(`Processed template from ${file} has been copied to the clipboard.`); 105 | console.log(`Total tokens: ${totalTokens}`); 106 | 107 | if (warnings.length > 0) { 108 | console.warn(warnings.join('\n')); 109 | } 110 | 111 | if (options.verbose && includedFiles) { 112 | console.log('\nIncluded files:'); 113 | Object.entries(includedFiles).forEach(([file, tokens]) => { 114 | console.log(`${file} [${tokens}]`); 115 | }); 116 | } 117 | } catch (error) { 118 | console.error('Error processing template file:', error); 119 | process.exit(1); 120 | } 121 | } 122 | 123 | async function handleCopyCommand(directory: string | undefined, options: Options) { 124 | if (options.file && options.file.length > 0) { 125 | const normalizedPaths = options.file.map(f => path.normalize(path.resolve(f))); 126 | await copyFilesToClipboard({filePaths: normalizedPaths}, options); 127 | } else if (directory) { 128 | const fullPath = path.resolve(directory); 129 | 130 | try { 131 | const stats = await fs.stat(fullPath); 132 | if (stats.isFile()) { 133 | await copyFilesToClipboard({filePaths: [fullPath]}, options); 134 | } else if (stats.isDirectory()) { 135 | console.log(`Copying files from ${path.normalize(directory)}`); 136 | await copyFilesToClipboard({directory: fullPath}, options); 137 | } else { 138 | console.error('Error: Provided path is neither a file nor a directory.'); 139 | process.exit(1); 140 | } 141 | } catch (error) { 142 | console.error('Error: Unable to resolve the provided path.', error); 143 | process.exit(1); 144 | } 145 | } else { 146 | console.error('Error: Please provide either a directory or use the --file option.'); 147 | process.exit(1); 148 | } 149 | } 150 | 151 | async function handleToCommand(file: string, options: { errors?: boolean, tokens?: boolean, verbose?: boolean }) { 152 | try { 153 | const globalExclude = await readGlobalConfig(); 154 | const { 155 | content, 156 | warnings, 157 | includedFiles, 158 | totalTokens 159 | } = await processPromptFile(path.resolve(file), globalExclude); 160 | 161 | if (options.errors) { 162 | if (warnings.length > 0) { 163 | console.log(warnings.join('\n')); 164 | } else { 165 | console.log(''); 166 | } 167 | } else if (options.tokens) { 168 | console.log(totalTokens); 169 | } else { 170 | console.log(content); 171 | 172 | if (options.verbose) { 173 | console.error(`\nProcessed template from ${file}`); 174 | console.error(`Total tokens: ${totalTokens}`); 175 | 176 | if (warnings.length > 0) { 177 | console.error('\nWarnings:'); 178 | console.error(warnings.join('\n')); 179 | } 180 | 181 | if (includedFiles) { 182 | console.error('\nIncluded files:'); 183 | Object.entries(includedFiles).forEach(([file, tokens]) => { 184 | console.error(`${file} [${tokens}]`); 185 | }); 186 | } 187 | } 188 | } 189 | } catch (error) { 190 | console.error('Error processing template file:', error); 191 | process.exit(1); 192 | } 193 | } 194 | 195 | 196 | program 197 | .name('copa') 198 | .description('CoPa: Prompt Engineering Templating Language and CLI Tool ') 199 | .version('1.6.2'); 200 | 201 | program 202 | .command('template ') 203 | .alias('t') 204 | .description('Process `.copa` template file and copy to clipboard') 205 | .option('-v, --verbose', 'Display detailed information about processed files and token counts') 206 | .action(handleTemplateCommand); 207 | 208 | program 209 | .command('copy [directory]') 210 | .alias('c') 211 | .description('Copy files from a directory or a single file to the clipboard') 212 | .option('-in, --include ', 'Comma-separated list of file patterns to include') 213 | .option('-ex, --exclude ', 'Comma-separated list of file extensions to exclude (in addition to global config)') 214 | .option('-v, --verbose', 'Display the list of copied files') 215 | .option('-f, --file ', 'Path to a single file to copy', (value, previous: string[]) => previous.concat([value]), []) 216 | .action(handleCopyCommand); 217 | 218 | program 219 | .command('to ') 220 | .description('Process a template file and output to stdout') 221 | .option('-err, --errors', 'Output only errors (like missing files)') 222 | .option('-t, --tokens', 'Output only the token count') 223 | .option('-v, --verbose', 'Display detailed information about processed files and token counts') 224 | .action(handleToCommand); 225 | 226 | program 227 | .action(() => { 228 | console.log('Please specify a command: "template" (or "t") or "copy" (or "c")'); 229 | program.outputHelp(); 230 | }); 231 | 232 | program.parse(process.argv); 233 | -------------------------------------------------------------------------------- /copa.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/promptProcessor.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import {encoding_for_model} from "@dqbd/tiktoken"; 4 | import {lev} from "./lev"; 5 | import {generateDirectoryTree} from "./directoryTree"; 6 | import {filterFiles} from "./filterFiles"; 7 | import {getFileContentAsText} from "./fileReader"; 8 | 9 | type PlaceholderType = 'file' | 'dir' | 'eval' | 'web'; 10 | 11 | interface PlaceholderOptions { 12 | type: PlaceholderType; 13 | isClean: boolean; 14 | isRemoveImports: boolean; 15 | ignorePatterns: string[]; 16 | includePatterns: string[]; 17 | } 18 | 19 | interface TextNode { 20 | type: 'text'; 21 | content: string; 22 | } 23 | 24 | interface FenceNode { 25 | type: 'fence'; 26 | content: string; 27 | } 28 | 29 | interface PlaceholderNode { 30 | type: 'placeholder'; 31 | original: string; // "{{@path/to/file:clean}}" 32 | resource: string; // "path/to/file" or "https://..." 33 | options: PlaceholderOptions; 34 | fenced?: boolean; // NEW: true when written as {{{ @... }}} 35 | } 36 | 37 | type TemplateNode = TextNode | PlaceholderNode | FenceNode; 38 | 39 | interface ProcessResult { 40 | content: string; 41 | warnings: string[]; 42 | includedFiles: { [filePath: string]: number }; 43 | totalTokens: number; 44 | } 45 | 46 | function longestBacktickRun(s: string): number { 47 | let max = 0, cur = 0; 48 | for (let i = 0; i < s.length; i++) { 49 | if (s[i] === '`') { 50 | cur++; 51 | if (cur > max) max = cur; 52 | } else { 53 | cur = 0; 54 | } 55 | } 56 | return max; 57 | } 58 | 59 | function toFencedBlock(body: string): string { 60 | const normalized = body.normalize('NFC'); 61 | const fenceLen = Math.max(3, longestBacktickRun(normalized) + 1); 62 | const fence = '`'.repeat(fenceLen); 63 | const withNewline = normalized.endsWith('\n') ? normalized : normalized + '\n'; 64 | return `${fence}\n${withNewline}${fence}\n\n`; 65 | } 66 | 67 | async function fetchWebPageContent(url: string): Promise { 68 | try { 69 | const response = await fetch(url); 70 | if (!response.ok) throw new Error(`HTTP error ${response.status}`); 71 | const contentType = response.headers.get('content-type'); 72 | if (contentType && (contentType.includes('text/html') || contentType.includes('text/plain') || contentType.includes('application/json') || contentType.includes('application/xml'))) { 73 | return await response.text(); 74 | } 75 | return `[Content from ${url} is not plain text (type: ${contentType})]`; 76 | } catch (error: any) { 77 | return `[Error fetching content from ${url}: ${error.message}]`; 78 | } 79 | } 80 | 81 | function countTokens(input: string): number { 82 | const tokenize = encoding_for_model('gpt-4'); 83 | try { 84 | return tokenize.encode(input.normalize('NFC')).length; 85 | } finally { 86 | tokenize.free(); 87 | } 88 | } 89 | 90 | function removeImportsFromFile(content: string, filePath: string): string { 91 | const extension = path.extname(filePath).toLowerCase(); 92 | if (extension !== '.ts' && extension !== '.tsx') return content; 93 | const importRegex = /^\s*import\s+(?:type\s+)?(?:[\w*{}\n\r\t, ]+)\s+from\s+["'].*?["'];?.*$/gm; 94 | let modifiedContent = content.replace(importRegex, '').replace(/(\r?\n){3,}/g, '\n'); 95 | return modifiedContent.trimStart(); 96 | } 97 | 98 | function formatFileContent(relativePath: string, content: string, modIndicator: string = ''): string { 99 | return `===== ${path.normalize(relativePath)}${modIndicator} =====\n${content}\n\n`; 100 | } 101 | 102 | 103 | const KNOWN_OPTIONS = ['dir', 'eval', 'clean', 'remove-imports']; 104 | const PRIMARY_OPTIONS: PlaceholderType[] = ['dir', 'eval']; 105 | 106 | 107 | function parsePlaceholder(placeholder: string, warnings: string[]): PlaceholderNode { 108 | const original = placeholder; 109 | const inner = placeholder.slice(3, -2); 110 | let resource = inner; 111 | let optionsStr = ''; 112 | 113 | const lastColonIndex = inner.lastIndexOf(':'); 114 | if (lastColonIndex > 0 && (inner.indexOf('://') === -1 || lastColonIndex > inner.indexOf('://') + 2)) { 115 | resource = inner.substring(0, lastColonIndex); 116 | optionsStr = inner.substring(lastColonIndex + 1); 117 | } 118 | 119 | const isWebUrl = resource.startsWith('http://') || resource.startsWith('https://'); 120 | const options: PlaceholderOptions = { 121 | type: isWebUrl ? 'web' : 'file', 122 | isClean: false, 123 | isRemoveImports: false, 124 | ignorePatterns: [], 125 | includePatterns: [], 126 | }; 127 | 128 | const parsedOpts = optionsStr.split(',').map(p => p.trim()).filter(Boolean); 129 | let primaryOpt: PlaceholderType | null = null; 130 | 131 | for (const opt of parsedOpts) { 132 | if (KNOWN_OPTIONS.includes(opt)) { 133 | if (PRIMARY_OPTIONS.includes(opt as PlaceholderType)) { 134 | if (primaryOpt) { 135 | warnings.push(`Warning: Multiple primary options (e.g., dir, eval) in "${original}". Using '${primaryOpt}'.`); 136 | } else { 137 | primaryOpt = opt as PlaceholderType; 138 | options.type = primaryOpt; 139 | } 140 | } else if (opt === 'clean') { 141 | options.isClean = true; 142 | } else if (opt === 'remove-imports') { 143 | options.isRemoveImports = true; 144 | } 145 | } else if (opt.startsWith('+')) { 146 | options.includePatterns.push(opt.slice(1)); 147 | } else if (opt.startsWith('-') || opt.includes('*')) { 148 | options.ignorePatterns.push(opt); 149 | } else { 150 | const a = lev(opt, 'remove-imports') 151 | const b = lev(opt, 'dir') 152 | const c = lev(opt, 'eval') 153 | const d = lev(opt, 'clean') 154 | 155 | const distances = { 156 | 'remove-imports': a, 157 | 'dir': b, 158 | 'eval': c, 159 | 'clean': d 160 | } 161 | 162 | const bestMatch = Object.entries(distances).reduce((a, b) => a[1] < b[1] ? a : b); 163 | 164 | let suggestion = ''; 165 | if (bestMatch[1] <= 2) { 166 | suggestion = ` Did you mean ':${bestMatch[0]}'?`; 167 | } 168 | warnings.push(`Warning: Unknown option ':${opt}' in "${original}". Ignoring.${suggestion}`); 169 | } 170 | } 171 | 172 | if (isWebUrl && (options.type === 'dir' || options.type === 'eval')) { 173 | warnings.push(`Warning: Option ':${options.type}' is not applicable to URLs in "${original}". Treating as a web request.`); 174 | options.type = 'web'; 175 | } 176 | if (options.type === 'dir' || options.type === 'eval') { 177 | if (options.isClean) warnings.push(`Warning: ':clean' is ignored with ':${options.type}' in "${original}".`); 178 | if (options.isRemoveImports) warnings.push(`Warning: ':remove-imports' is ignored with ':${options.type}' in "${original}".`); 179 | options.isClean = false; 180 | options.isRemoveImports = false; 181 | } 182 | 183 | return {type: 'placeholder', original, resource, options}; 184 | } 185 | 186 | function parseTemplateToAST(template: string, warnings: string[]): TemplateNode[] { 187 | const ignoreBelowMarker = '{{!IGNORE_BELOW}}'; 188 | const ignoreBelowIndex = template.indexOf(ignoreBelowMarker); 189 | if (ignoreBelowIndex !== -1) { 190 | template = template.substring(0, ignoreBelowIndex); 191 | } 192 | template = template.replace(/{{![\s\S]*?}}/g, ''); 193 | 194 | // Capture: 195 | // - fence blocks: {{{ ... }}} (multiline) 196 | // - placeholders: {{@ ... }} (single line) 197 | const regex = /({{{[\s\S]*?}}}|{{@(?:.*?)}})/g; 198 | const parts = template.split(regex); 199 | const ast: TemplateNode[] = []; 200 | 201 | for (const part of parts) { 202 | if (!part) continue; 203 | 204 | if (part.startsWith('{{{') && part.endsWith('}}}')) { 205 | const inner = part.slice(3, -3); 206 | const trimmed = inner.trim(); 207 | 208 | // NEW: triple-brace placeholder sugar {{{ @... }}} 209 | if (trimmed.startsWith('@')) { 210 | // Reuse the existing placeholder parser 211 | const ph = parsePlaceholder(`{{${trimmed}}}`, warnings); 212 | ph.fenced = true; 213 | ast.push(ph); 214 | } else { 215 | ast.push({type: 'fence', content: inner}); 216 | } 217 | } else if (part.startsWith('{{@') && part.endsWith('}}')) { 218 | if (part.length > 5) { 219 | ast.push(parsePlaceholder(part, warnings)); 220 | } 221 | } else { 222 | ast.push({type: 'text', content: part}); 223 | } 224 | } 225 | return ast; 226 | } 227 | 228 | 229 | async function processNode( 230 | node: TemplateNode, 231 | basePath: string, 232 | warnings: string[], 233 | globalExclude?: string 234 | ): Promise<{ content: string; includedFiles: { [key: string]: number }, tokens: number }> { 235 | if (node.type === 'text') { 236 | const tokenCount = countTokens(node.content); 237 | return {content: node.content, includedFiles: {}, tokens: tokenCount}; 238 | } 239 | 240 | if (node.type === 'fence') { 241 | const inner = await processPromptTemplate( 242 | node.content.normalize('NFC'), 243 | basePath, 244 | warnings, 245 | globalExclude 246 | ); 247 | 248 | const fenced = toFencedBlock(inner.content); 249 | 250 | const includedFiles: { [key: string]: number } = {}; 251 | for (const [k, v] of Object.entries(inner.includedFiles)) { 252 | includedFiles[`fence:${k}`] = v; 253 | } 254 | 255 | return {content: fenced, includedFiles, tokens: countTokens(fenced)}; 256 | } 257 | 258 | const {resource, options} = node; 259 | let content = ''; 260 | let includedFiles: { [key: string]: number } = {}; 261 | let tokens = 0; 262 | 263 | try { 264 | switch (options.type) { 265 | case 'web': { 266 | const webContent = await fetchWebPageContent(resource); 267 | content = options.isClean ? webContent : `===== ${resource} =====\n${webContent}\n\n`; 268 | tokens = countTokens(content); 269 | includedFiles[`${resource} (web page${options.isClean ? ', clean' : ''})`] = tokens; 270 | break; 271 | } 272 | case 'dir': { 273 | const absolutePath = path.resolve(basePath, resource); 274 | const treeContent = await generateDirectoryTree(absolutePath, options.ignorePatterns, options.includePatterns); 275 | content = `===== Directory Structure: ${resource} =====\n${treeContent}\n\n`; 276 | tokens = countTokens(content); 277 | includedFiles[`${resource} (directory tree)`] = tokens; 278 | break; 279 | } 280 | case 'eval': { 281 | const absolutePath = path.resolve(basePath, resource); 282 | const templateToEval = await fs.readFile(absolutePath, 'utf-8'); 283 | const evalBasePath = path.dirname(absolutePath); 284 | const evalResult = await processPromptTemplate(templateToEval.normalize('NFC'), evalBasePath, warnings, globalExclude); 285 | 286 | content = evalResult.content; 287 | tokens = evalResult.totalTokens; 288 | Object.entries(evalResult.includedFiles).forEach(([key, value]) => { 289 | includedFiles[`eval:${resource}:${key}`] = value; 290 | }); 291 | break; 292 | } 293 | case 'file': { 294 | const absolutePath = path.resolve(basePath, resource); 295 | const pathResult = await processPath(absolutePath, options.ignorePatterns, options.includePatterns, globalExclude, basePath); 296 | warnings.push(...pathResult.warnings); 297 | 298 | if (pathResult.files.length === 0) throw new Error(`Path [${resource}] not found or yielded no files.`); 299 | 300 | let concatenatedContent = ''; 301 | let totalNodeTokens = 0; 302 | 303 | for (const file of pathResult.files) { 304 | let fileContent = file.content.normalize('NFC'); 305 | let modIndicator = ''; 306 | if (options.isRemoveImports) { 307 | const originalContent = fileContent; 308 | fileContent = removeImportsFromFile(fileContent, file.fullPath); 309 | if (fileContent !== originalContent) { 310 | modIndicator = ' (imports removed)'; 311 | } 312 | } 313 | 314 | let finalFileRepresentation: string; 315 | let includedFileKey: string; 316 | 317 | if (options.isClean) { 318 | finalFileRepresentation = fileContent; 319 | includedFileKey = `${file.relativePath} (clean${modIndicator})`; 320 | } else { 321 | finalFileRepresentation = formatFileContent(file.relativePath, fileContent, modIndicator); 322 | includedFileKey = `${file.relativePath}${modIndicator}`; 323 | } 324 | 325 | const fileTokens = countTokens(finalFileRepresentation); 326 | concatenatedContent += finalFileRepresentation; 327 | totalNodeTokens += fileTokens; 328 | includedFiles[includedFileKey] = fileTokens; 329 | } 330 | 331 | content = concatenatedContent; 332 | tokens = totalNodeTokens; 333 | break; 334 | } 335 | } 336 | } catch (error: any) { 337 | warnings.push(`Warning: Error processing placeholder "${node.original}": ${error.message}`); 338 | content = `[Error processing placeholder: ${resource} - ${error.message}]\n`; 339 | tokens = countTokens(content); 340 | } 341 | 342 | if (node.fenced) { 343 | const fenced = toFencedBlock(content); 344 | 345 | // For consistency with regular fence nodes, prefix included file keys 346 | const prefixed: { [key: string]: number } = {}; 347 | for (const [k, v] of Object.entries(includedFiles)) { 348 | prefixed[`fence:${k}`] = v; 349 | } 350 | 351 | content = fenced; 352 | tokens = countTokens(fenced); 353 | includedFiles = prefixed; 354 | } 355 | 356 | return {content, includedFiles, tokens}; 357 | } 358 | 359 | 360 | export async function processPromptFile(promptFilePath: string, globalExclude?: string): Promise { 361 | const content = await fs.readFile(promptFilePath, 'utf-8'); 362 | const warnings: string[] = []; 363 | const result = await processPromptTemplate(content.normalize('NFC'), path.dirname(promptFilePath), warnings, globalExclude); 364 | return {...result, warnings}; 365 | } 366 | 367 | async function processPromptTemplate(template: string, basePath: string, warnings: string[], globalExclude?: string): Promise { 368 | const ast = parseTemplateToAST(template, warnings); 369 | 370 | let finalContent = ''; 371 | const allIncludedFiles: { [filePath: string]: number } = {}; 372 | let totalTokens = 0; 373 | 374 | for (const node of ast) { 375 | const result = await processNode(node, basePath, warnings, globalExclude); 376 | finalContent += result.content; 377 | Object.assign(allIncludedFiles, result.includedFiles); 378 | totalTokens += result.tokens; 379 | } 380 | 381 | return {content: finalContent, includedFiles: allIncludedFiles, totalTokens, warnings}; 382 | } 383 | 384 | async function processPath(absolutePathToProcess: string, ignorePatterns: string[], includePatterns: string[], globalExclude: string | undefined, 385 | templateBasePath: string): Promise<{ 386 | files: Array<{ relativePath: string; content: string; fullPath: string }>; 387 | warnings: string[]; 388 | }> { 389 | const warnings: string[] = []; 390 | const filteredFiles = await filterFiles({ 391 | exclude: ignorePatterns.join(','), 392 | include: includePatterns.join(',') 393 | }, absolutePathToProcess, globalExclude); 394 | if (filteredFiles === undefined) { 395 | throw new Error(`Path [${path.relative(templateBasePath, absolutePathToProcess) || path.basename(absolutePathToProcess)}] not found.`); 396 | } 397 | if (filteredFiles.length === 0) { 398 | throw new Error(`Path [${path.relative(templateBasePath, absolutePathToProcess) || path.basename(absolutePathToProcess)}] yielded no files after filtering.`); 399 | } 400 | const filesData: Array<{ relativePath: string; content: string; fullPath: string }> = []; 401 | for (const file of filteredFiles) { 402 | try { 403 | const fullPath = path.normalize(file); 404 | const fileContent = await getFileContentAsText(fullPath); 405 | const relativePath = path.normalize(path.relative(templateBasePath, fullPath)); 406 | filesData.push({relativePath, content: fileContent, fullPath}); 407 | } catch (readError: any) { 408 | const errorMessage = `[Error processing file ${path.basename(file)}: ${readError.message}]`; 409 | warnings.push(`Warning: Could not fully process file ${file}: ${readError.message}`); 410 | const relativePath = path.normalize(path.relative(templateBasePath, file)); 411 | filesData.push({relativePath, content: errorMessage, fullPath: file}); 412 | } 413 | } 414 | return {files: filesData, warnings}; 415 | } 416 | -------------------------------------------------------------------------------- /tests/promptProcessorFileContentModifiers.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import * as os from 'os'; 4 | import {describe, beforeEach, afterEach, expect, test} from 'vitest' 5 | import {encoding_for_model} from "@dqbd/tiktoken"; 6 | import {processPromptFile} from "../src/promptProcessor"; 7 | 8 | 9 | function countTokens(input: string): number { 10 | // IMPORTANT: Ensure you have the correct model name if not 'gpt-4' 11 | const tokenize = encoding_for_model('gpt-4'); 12 | try { 13 | // Normalize helps ensure consistent token counts 14 | const normalizedInput = input.normalize('NFC'); 15 | return tokenize.encode(normalizedInput).length; 16 | } finally { 17 | tokenize.free(); // Essential to prevent memory leaks 18 | } 19 | } 20 | 21 | const normalizePathsInData = (data: { content: string; includedFiles: { [key: string]: number } }, testDir: string) => { 22 | // Escape backslashes in testDir for regex and ensure trailing separator 23 | const escapedTestDir = testDir.replace(/\\/g, '\\\\') + (path.sep === '\\' ? '\\\\' : path.sep); 24 | const regex = new RegExp(escapedTestDir, 'g'); 25 | 26 | const normalizedContent = data.content.replace(regex, ''); // Use regex directly 27 | const normalizedIncludedFiles: { [key: string]: number } = {}; 28 | for (const [key, value] of Object.entries(data.includedFiles)) { 29 | normalizedIncludedFiles[key.replace(regex, '')] = value; 30 | } 31 | return {...data, content: normalizedContent, includedFiles: normalizedIncludedFiles}; 32 | } 33 | 34 | 35 | describe('Prompt Processor: :remove-imports modifier (code and type imports)', () => { 36 | let testDir: string; 37 | let srcDir: string; 38 | 39 | // --- Updated Test File Contents --- 40 | const tsContentWithImports = ` 41 | import { Component } from '@angular/core'; 42 | import * as utils from './utils'; 43 | import type { SomeType } from 'some-lib'; 44 | 45 | // Some comment 46 | console.log('Hello from TS'); 47 | 48 | export class MyComponent { 49 | // Component logic 50 | } 51 | 52 | const anotherVar = require('./old-style'); 53 | require('side-effect-import'); 54 | export { Utils } from "./another-util"; 55 | export * from './reexport-all'; 56 | `; 57 | 58 | // Expected content after removing code and type imports 59 | const expected_tsContent_Cleaned = ` 60 | 61 | // Some comment 62 | console.log('Hello from TS'); 63 | 64 | export class MyComponent { 65 | // Component logic 66 | } 67 | 68 | const anotherVar = require('./old-style'); 69 | require('side-effect-import'); 70 | export { Utils } from "./another-util"; 71 | export * from './reexport-all'; 72 | `.trimStart(); // removeImportsFromFile also trims start 73 | 74 | const tsxContentWithImports = ` 75 | import React, { useState, useEffect } from 'react'; // Will be removed 76 | import styles from './styles.module.css'; // Will be removed 77 | import type { CSSProperties } from 'react'; // Will be removed 78 | import './global-styles.css'; // Keep (Side Effect) 79 | 80 | type Props = { 81 | message: string; 82 | }; 83 | 84 | export default function MyReactComponent({ message }: Props) { 85 | const [count, setCount] = useState(0); 86 | 87 | useEffect(() => { 88 | // effect logic 89 | }, []); 90 | 91 | // Type usage remains valid even if the import type line is gone 92 | const style: CSSProperties = { color: 'blue' }; 93 | 94 | return
{message} - {count}
; 95 | } 96 | export type { Props as ComponentProps } from './types'; // Keep Re-export type 97 | `; 98 | 99 | // Expected content after removing code and type imports 100 | const expected_tsxContent_Cleaned = ` 101 | 102 | import './global-styles.css'; // Keep (Side Effect) 103 | 104 | type Props = { 105 | message: string; 106 | }; 107 | 108 | export default function MyReactComponent({ message }: Props) { 109 | const [count, setCount] = useState(0); 110 | 111 | useEffect(() => { 112 | // effect logic 113 | }, []); 114 | 115 | // Type usage remains valid even if the import type line is gone 116 | const style: CSSProperties = { color: 'blue' }; 117 | 118 | return
{message} - {count}
; 119 | } 120 | export type { Props as ComponentProps } from './types'; // Keep Re-export type 121 | `.trimStart(); 122 | 123 | const jsContentWithRequire = ` 124 | const fs = require('fs'); // Keep 125 | const path = require('path'); // Keep 126 | 127 | function logStuff() { 128 | console.log('Logging from JS'); 129 | } 130 | 131 | module.exports = { logStuff }; 132 | `; // JS files are not modified by the function 133 | 134 | const mdContent = `# Markdown File 135 | 136 | This should not be affected by remove-imports. 137 | `; // Non-code files are not modified 138 | 139 | const onlyImportsContent = `import { a } from 'a';\nimport 'b';\nimport type { C } from 'c';`; // Original 140 | 141 | // Expected content after removing code and type imports from onlyImportsContent 142 | // Only the side-effect import 'b' remains. trimStart() is applied by the function. 143 | const expected_onlyImports_Cleaned = `import 'b';`; 144 | 145 | // --- End Test File Contents --- 146 | 147 | 148 | beforeEach(async () => { 149 | // Create a temporary directory for test files 150 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'copa-remove-imports-test-')); 151 | srcDir = path.join(testDir, 'src'); 152 | await fs.mkdir(srcDir); 153 | await fs.mkdir(path.join(srcDir, 'components')); 154 | 155 | // Write test files with updated content 156 | await fs.writeFile(path.join(srcDir, 'main.ts'), tsContentWithImports); 157 | await fs.writeFile(path.join(srcDir, 'components', 'component.tsx'), tsxContentWithImports); 158 | await fs.writeFile(path.join(srcDir, 'utils.js'), jsContentWithRequire); 159 | await fs.writeFile(path.join(testDir, 'docs.md'), mdContent); 160 | await fs.writeFile(path.join(srcDir, 'empty.ts'), ''); // Edge case: empty file 161 | await fs.writeFile(path.join(srcDir, 'only_imports.ts'), onlyImportsContent); // Edge case: only imports 162 | }); 163 | 164 | afterEach(async () => { 165 | // Clean up the temporary directory 166 | await fs.rm(testDir, {recursive: true, force: true}); 167 | }); 168 | 169 | // --- Updated Tests --- 170 | 171 | test('should remove code and type imports from a single .ts file using :remove-imports', async () => { 172 | const promptContent = `Analyze this TS code:\n{{@src/main.ts:remove-imports}}`; 173 | const promptFile = path.join(testDir, 'prompt.copa'); 174 | await fs.writeFile(promptFile, promptContent); 175 | 176 | const result = await processPromptFile(promptFile); 177 | const normalizedResult = normalizePathsInData(result, testDir); 178 | 179 | // Use the NEW expected cleaned content 180 | const expectedCleanedTsContent = expected_tsContent_Cleaned; 181 | const expectedFileName = path.normalize('src/main.ts'); // Keep normalized path 182 | const expectedWrapper = `===== ${expectedFileName} (imports removed) =====`; 183 | const expectedOutput = `Analyze this TS code:\n${expectedWrapper}\n${expectedCleanedTsContent}\n\n`; 184 | 185 | expect(result.content).toBe(expectedOutput); 186 | expect(result.warnings).toEqual([]); 187 | expect(normalizedResult.includedFiles).toEqual({ 188 | [`${path.normalize('src/main.ts')} (imports removed)`]: countTokens(`${expectedWrapper}\n${expectedCleanedTsContent}\n\n`) 189 | }); 190 | expect(result.totalTokens).toBe(countTokens(expectedOutput)); 191 | }); 192 | 193 | test('should remove code and type imports from a single .tsx file using :remove-imports', async () => { 194 | const promptContent = `Analyze this TSX component:\n{{@src/components/component.tsx:remove-imports}}`; 195 | const promptFile = path.join(testDir, 'prompt.copa'); 196 | await fs.writeFile(promptFile, promptContent); 197 | 198 | const result = await processPromptFile(promptFile); 199 | const normalizedResult = normalizePathsInData(result, testDir); 200 | 201 | // Use the NEW expected cleaned content 202 | const expectedCleanedTsxContent = expected_tsxContent_Cleaned; 203 | const expectedFileName = path.normalize('src/components/component.tsx'); 204 | const expectedWrapper = `===== ${expectedFileName} (imports removed) =====`; 205 | const expectedOutput = `Analyze this TSX component:\n${expectedWrapper}\n${expectedCleanedTsxContent}\n\n`; 206 | 207 | // Use trim() for comparison robustness against trailing whitespace differences 208 | expect(result.content.trim()).toBe(expectedOutput.trim()); 209 | expect(result.warnings).toEqual([]); 210 | expect(normalizedResult.includedFiles).toEqual({ 211 | [`${path.normalize('src/components/component.tsx')} (imports removed)`]: countTokens(`${expectedWrapper}\n${expectedCleanedTsxContent}\n\n`) 212 | }); 213 | expect(result.totalTokens).toBe(countTokens(expectedOutput)); 214 | }); 215 | 216 | test('should NOT remove imports/requires from a .js file even if :remove-imports is specified', async () => { 217 | const promptContent = `Analyze this JS code:\n{{@src/utils.js:remove-imports}}`; 218 | const promptFile = path.join(testDir, 'prompt.copa'); 219 | await fs.writeFile(promptFile, promptContent); 220 | 221 | const result = await processPromptFile(promptFile); 222 | const normalizedResult = normalizePathsInData(result, testDir); 223 | 224 | const expectedJsContent = jsContentWithRequire; // Original content 225 | const expectedFileName = path.normalize('src/utils.js'); 226 | const expectedWrapper = `===== ${expectedFileName} =====`; // No "(imports removed)" 227 | const expectedOutput = `Analyze this JS code:\n${expectedWrapper}\n${expectedJsContent}\n\n`; 228 | 229 | expect(result.content).toBe(expectedOutput); 230 | expect(result.warnings).toEqual([]); // No warning needed, it just doesn't apply 231 | expect(normalizedResult.includedFiles).toEqual({ 232 | [path.normalize('src/utils.js')]: countTokens(`${expectedWrapper}\n${expectedJsContent}\n\n`) 233 | }); 234 | expect(result.totalTokens).toBe(countTokens(expectedOutput)); 235 | }); 236 | 237 | test('should NOT remove imports from a non-code file (.md) even if :remove-imports is specified', async () => { 238 | const promptContent = `Include docs:\n{{@docs.md:remove-imports}}`; 239 | const promptFile = path.join(testDir, 'prompt.copa'); 240 | await fs.writeFile(promptFile, promptContent); 241 | 242 | const result = await processPromptFile(promptFile); 243 | const normalizedResult = normalizePathsInData(result, testDir); 244 | 245 | const expectedMdContent = mdContent; 246 | const expectedFileName = path.normalize('docs.md'); 247 | const expectedWrapper = `===== ${expectedFileName} =====`; 248 | const expectedOutput = `Include docs:\n${expectedWrapper}\n${expectedMdContent}\n\n`; 249 | 250 | expect(result.content).toBe(expectedOutput); 251 | expect(result.warnings).toEqual([]); 252 | expect(normalizedResult.includedFiles).toEqual({ 253 | [path.normalize('docs.md')]: countTokens(`${expectedWrapper}\n${expectedMdContent}\n\n`) 254 | }); 255 | expect(result.totalTokens).toBe(countTokens(expectedOutput)); 256 | }); 257 | 258 | test('should remove code/type imports from .ts/.tsx files when importing a directory with :remove-imports', async () => { 259 | const promptContent = `Analyze the src directory:\n{{@src:remove-imports}}`; 260 | const promptFile = path.join(testDir, 'prompt.copa'); 261 | await fs.writeFile(promptFile, promptContent); 262 | 263 | const result = await processPromptFile(promptFile); 264 | const normalizedResult = normalizePathsInData(result, testDir); 265 | 266 | // Use the NEW expected cleaned content strings 267 | const expectedCleanedTsContent = expected_tsContent_Cleaned; 268 | const expectedCleanedTsxContent = expected_tsxContent_Cleaned; 269 | const expectedJsContent = jsContentWithRequire; // Unchanged 270 | const expectedEmptyTsContent = ''; // Empty file remains empty 271 | const expectedCleanedOnlyImportsContent = expected_onlyImports_Cleaned; // Updated expectation 272 | 273 | // Note: Order might depend on glob/fs results, but content should be correct. 274 | // Check presence and absence of key parts. 275 | 276 | // Check TS file 277 | const tsPath = path.normalize('src/main.ts'); 278 | expect(result.content).toContain(`===== ${tsPath} (imports removed) =====\n${expectedCleanedTsContent}\n\n`); 279 | expect(result.content).not.toContain(`import { Component } from '@angular/core';`); 280 | expect(result.content).not.toContain(`import type { SomeType } from 'some-lib';`); 281 | expect(result.content).toContain(`require('side-effect-import');`); // Kept 282 | 283 | // Check TSX file 284 | const tsxPath = path.normalize('src/components/component.tsx'); 285 | expect(result.content).toContain(`===== ${tsxPath} (imports removed) =====\n${expectedCleanedTsxContent}\n\n`); 286 | expect(result.content).not.toContain(`import React, { useState, useEffect } from 'react';`); 287 | expect(result.content).not.toContain(`import type { CSSProperties } from 'react';`); 288 | expect(result.content).toContain(`import './global-styles.css';`); // Kept 289 | 290 | // Check JS file 291 | const jsPath = path.normalize('src/utils.js'); 292 | expect(result.content).toContain(`===== ${jsPath} =====\n${expectedJsContent}\n\n`); // No "(imports removed)" 293 | expect(result.content).toContain(`const fs = require('fs');`); // Imports remain 294 | 295 | // Check empty TS file 296 | const emptyTsPath = path.normalize('src/empty.ts'); 297 | // Empty file might or might not get '(imports removed)' depending on implementation, accept either 298 | expect(result.content).toMatch(new RegExp(`===== ${emptyTsPath.replace(/\\/g, '\\\\')}(?: \\(imports removed\\))? =====\\n${expectedEmptyTsContent}\\n\\n`)); 299 | 300 | 301 | // Check only-imports TS file 302 | const onlyImportsPath = path.normalize('src/only_imports.ts'); 303 | expect(result.content).toContain(`===== ${onlyImportsPath} (imports removed) =====\n${expectedCleanedOnlyImportsContent}\n\n`); 304 | expect(result.content).not.toContain(`import { a } from 'a';`); 305 | expect(result.content).not.toContain(`import type { C } from 'c';`); 306 | 307 | 308 | expect(result.warnings).toEqual([]); 309 | 310 | // Verify included files list reflects the changes (presence check) 311 | expect(normalizedResult.includedFiles).toHaveProperty(`${path.normalize('src/main.ts')} (imports removed)`); 312 | expect(normalizedResult.includedFiles).toHaveProperty(`${path.normalize('src/components/component.tsx')} (imports removed)`); 313 | expect(normalizedResult.includedFiles).toHaveProperty(path.normalize('src/utils.js')); 314 | expect(normalizedResult.includedFiles).toHaveProperty(`${path.normalize('src/only_imports.ts')} (imports removed)`); 315 | expect(normalizedResult.includedFiles).toHaveProperty(path.normalize('src/empty.ts')); // Or possibly with '(imports removed)' 316 | }); 317 | 318 | test('should remove code/type imports when using :remove-imports and :clean together on a directory', async () => { 319 | const promptContent = `Cleaned src code:\n{{@src:remove-imports,clean}}`; 320 | const promptFile = path.join(testDir, 'prompt.copa'); 321 | await fs.writeFile(promptFile, promptContent); 322 | 323 | const result = await processPromptFile(promptFile); 324 | const normalizedResult = normalizePathsInData(result, testDir); 325 | 326 | // Use the NEW expected cleaned content strings 327 | const expectedCleanedTsContent = expected_tsContent_Cleaned; 328 | const expectedCleanedTsxContent = expected_tsxContent_Cleaned; 329 | const expectedJsContent = jsContentWithRequire; // Unchanged 330 | const expectedEmptyTsContent = ''; 331 | const expectedCleanedOnlyImportsContent = expected_onlyImports_Cleaned; 332 | 333 | // Check presence/absence in the concatenated content 334 | expect(result.content).not.toContain('====='); // No wrappers 335 | // TS checks 336 | expect(result.content).toContain(expectedCleanedTsContent); 337 | expect(result.content).not.toContain(`import { Component } from '@angular/core';`); 338 | expect(result.content).not.toContain(`import type { SomeType } from 'some-lib';`); 339 | expect(result.content).toContain(`require('side-effect-import');`); // Kept 340 | // TSX checks 341 | expect(result.content).toContain(expectedCleanedTsxContent); 342 | expect(result.content).not.toContain(`import React, { useState, useEffect } from 'react';`); 343 | expect(result.content).not.toContain(`import type { CSSProperties } from 'react';`); 344 | expect(result.content).toContain(`import './global-styles.css';`); // Kept 345 | // JS checks 346 | expect(result.content).toContain(expectedJsContent); 347 | expect(result.content).toContain(`const fs = require('fs');`); // JS require should remain 348 | // only_imports check 349 | expect(result.content).toContain(expectedCleanedOnlyImportsContent); 350 | expect(result.content).not.toContain(`import { a } from 'a';`); 351 | // Check included files have the (clean) suffix and imports removed status where applicable 352 | expect(normalizedResult.includedFiles).toHaveProperty(`${path.normalize('src/main.ts')} (clean (imports removed))`); 353 | expect(normalizedResult.includedFiles).toHaveProperty(`${path.normalize('src/components/component.tsx')} (clean (imports removed))`); 354 | expect(normalizedResult.includedFiles).toHaveProperty(`${path.normalize('src/utils.js')} (clean)`); // JS not modified 355 | expect(normalizedResult.includedFiles).toHaveProperty(`${path.normalize('src/only_imports.ts')} (clean (imports removed))`); 356 | // Empty file might or might not get '(imports removed)', accept either 357 | expect(Object.keys(normalizedResult.includedFiles)).toEqual( 358 | expect.arrayContaining([expect.stringMatching(/^src[\\\/]empty\.ts(?: \(imports removed\))? \(clean\)$/)]) 359 | ); 360 | 361 | 362 | expect(Math.abs(result.totalTokens - countTokens(result.content))).toBeLessThan(3); 363 | }); 364 | 365 | test('should ignore :remove-imports when used with :dir', async () => { 366 | const promptContent = `Directory structure:\n{{@src:remove-imports,dir}}`; 367 | const promptFile = path.join(testDir, 'prompt.copa'); 368 | await fs.writeFile(promptFile, promptContent); 369 | 370 | const result = await processPromptFile(promptFile); 371 | const normalizedResult = normalizePathsInData(result, testDir); 372 | 373 | expect(result.content).toContain('===== Directory Structure: src ====='); 374 | expect(result.content).toContain('main.ts'); 375 | expect(result.content).toContain('components' + path.sep); // Use path.sep for cross-platform compatibility 376 | expect(result.content).toContain(`├── components/ 377 | │ └── component.tsx`); // Check nested file display 378 | expect(result.warnings).toEqual(expect.arrayContaining([ 379 | expect.stringMatching(/Warning: ':remove-imports' is ignored with.*/) 380 | ])); 381 | expect(normalizedResult.includedFiles).toHaveProperty('src (directory tree)'); 382 | }); 383 | 384 | test('should ignore :remove-imports when used with :eval', async () => { 385 | // Create a nested template file that includes the TS file *without* modification 386 | const nestedTemplateContent = `Nested content:\n{{@src/main.ts}}`; // No :remove-imports here 387 | const nestedTemplateFile = path.join(testDir, 'nested.copa'); 388 | await fs.writeFile(nestedTemplateFile, nestedTemplateContent); 389 | 390 | // Apply :remove-imports to the :eval placeholder itself 391 | const promptContent = `Evaluated template:\n{{@nested.copa:remove-imports,eval}}`; 392 | const promptFile = path.join(testDir, 'prompt.copa'); 393 | await fs.writeFile(promptFile, promptContent); 394 | 395 | const result = await processPromptFile(promptFile); 396 | const normalizedResult = normalizePathsInData(result, testDir); 397 | 398 | // Expect the *original* content of main.ts, as :remove-imports is ignored by :eval 399 | // The file included *within* nested.copa should be the original tsContentWithImports 400 | const expectedNestedOutput = `Nested content:\n===== ${path.normalize('src/main.ts')} =====\n${tsContentWithImports}\n\n`; 401 | const expectedFinalOutput = `Evaluated template:\n${expectedNestedOutput}`; 402 | 403 | expect(result.content).toBe(expectedFinalOutput); 404 | // Verify original imports are present 405 | expect(result.content).toContain(`import { Component } from '@angular/core';`); 406 | expect(result.content).toContain(`import type { SomeType } from 'some-lib';`); 407 | 408 | expect(result.warnings).toEqual(expect.arrayContaining([ 409 | expect.stringMatching(/Warning: ':remove-imports' is ignored.*/) 410 | ])); 411 | // Check included files are prefixed correctly for eval, and reflect the original file from the nested template 412 | expect(normalizedResult.includedFiles).toHaveProperty(`eval:nested.copa:${path.normalize('src/main.ts')}`); 413 | // Verify token count matches the *unevaluated* file included via eval 414 | expect(normalizedResult.includedFiles[`eval:nested.copa:${path.normalize('src/main.ts')}`]).toBe(countTokens(`===== ${path.normalize('src/main.ts')} =====\n${tsContentWithImports}\n\n`)) 415 | }); 416 | }); -------------------------------------------------------------------------------- /tests/promptProcessor.test.ts: -------------------------------------------------------------------------------- 1 | import * as fs from 'fs/promises'; 2 | import * as path from 'path'; 3 | import * as os from 'os'; 4 | import {filterFiles} from "../src/filterFiles"; 5 | import {describe, beforeEach, afterEach, expect, test} from 'vitest' 6 | import {processPromptFile} from "../src/promptProcessor"; 7 | import {exec as _exec} from 'child_process'; 8 | import {promisify} from 'util'; 9 | 10 | const exec = promisify(_exec); 11 | 12 | describe('Prompt Processor', () => { 13 | let testDir: string; 14 | 15 | const cleanPath = (path: string) => path.replace(testDir + '/', ''); 16 | 17 | beforeEach(async () => { 18 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'copa-prompt-test-')); 19 | await fs.mkdir(path.join(testDir, 'subdir')); 20 | await fs.writeFile(path.join(testDir, 'file1.js'), 'console.log("Hello");'); 21 | await fs.writeFile(path.join(testDir, 'file2.md'), '# Markdown'); 22 | await fs.writeFile(path.join(testDir, 'subdir', 'file3.txt'), 'Nested file content'); 23 | }); 24 | 25 | afterEach(async () => { 26 | await fs.rm(testDir, {recursive: true, force: true}); 27 | }); 28 | 29 | test('processes a prompt file with single file reference', async () => { 30 | const promptContent = 'This is a test prompt.\n{{@file1.js}}\nEnd of prompt.'; 31 | const promptFile = path.join(testDir, 'prompt.txt'); 32 | await fs.writeFile(promptFile, promptContent); 33 | 34 | const result = await processPromptFile(promptFile); 35 | 36 | expect(result.content).toContain('This is a test prompt.'); 37 | expect(result.content).toContain('file1.js ====='); 38 | expect(result.content).toContain('console.log("Hello");'); 39 | expect(result.content).toContain('End of prompt.'); 40 | }); 41 | 42 | test('processes a prompt file with multiple file references', async () => { 43 | const promptContent = 'Files:\n{{@file1.js}}\n{{@file2.md}}\nEnd.'; 44 | const promptFile = path.join(testDir, 'prompt.txt'); 45 | await fs.writeFile(promptFile, promptContent); 46 | 47 | const result = await processPromptFile(promptFile); 48 | 49 | expect(result.content).toContain('Files:'); 50 | expect(result.content).toContain('file1.js ====='); 51 | expect(result.content).toContain('console.log("Hello");'); 52 | expect(result.content).toContain('file2.md ====='); 53 | expect(result.content).toContain('# Markdown'); 54 | expect(result.content).toContain('End.'); 55 | }); 56 | 57 | test('processes a prompt file with folder reference', async () => { 58 | const promptContent = 'Folder contents:\n{{@subdir}}\nEnd of folder.'; 59 | const promptFile = path.join(testDir, 'prompt.txt'); 60 | await fs.writeFile(promptFile, promptContent); 61 | 62 | const result = await processPromptFile(promptFile); 63 | 64 | expect(result.content).toContain('Folder contents:'); 65 | expect(result.content).toContain('===== subdir/file3.txt ====='); 66 | expect(result.content).toContain('Nested file content'); 67 | expect(result.content).toContain('End of folder.'); 68 | }); 69 | 70 | test('handles non-existent file references gracefully', async () => { 71 | const promptContent = 'Missing file:\n{{@nonexistent.txt}}\nEnd.'; 72 | const promptFile = path.join(testDir, 'prompt.txt'); 73 | await fs.writeFile(promptFile, promptContent); 74 | 75 | const {content, warnings} = await processPromptFile(promptFile); 76 | 77 | expect(content).toContain('Missing file:'); 78 | expect(content).not.toContain('===== nonexistent.txt ====='); 79 | expect(content).toContain('End.'); 80 | expect(warnings).toHaveLength(1); 81 | expect(warnings[0]).toContain('Warning: Error processing placeholder'); 82 | expect(warnings[0]).toContain('nonexistent.txt'); 83 | }); 84 | 85 | test('processes a prompt file with ignore patterns', async () => { 86 | const promptContent = 'Files:\n{{@.:-*.md,-**/subdir/**}}\nEnd.'; 87 | const promptFile = path.join(testDir, 'prompt.txt'); 88 | await fs.writeFile(promptFile, promptContent); 89 | 90 | const result = await processPromptFile(promptFile); 91 | 92 | expect(result.content).toContain('Files:'); 93 | expect(result.content).toContain('file1.js ====='); 94 | expect(result.content).toContain('console.log("Hello");'); 95 | expect(result.content).not.toContain('file2.md ====='); 96 | expect(result.content).not.toContain('# Markdown'); 97 | expect(result.content).not.toContain('subdir/file'); 98 | expect(result.content).toContain('End.'); 99 | }); 100 | 101 | test('processes a prompt file with multiple ignore patterns', async () => { 102 | const promptContent = 'Files:\n{{@.:-*.md,-*.yml,-**/subdir/file4.js}}\nEnd.'; 103 | const promptFile = path.join(testDir, 'prompt.txt'); 104 | await fs.writeFile(promptFile, promptContent); 105 | 106 | const result = await processPromptFile(promptFile); 107 | 108 | expect(result.content).toContain('Files:'); 109 | expect(result.content).toContain('file1.js ====='); 110 | expect(result.content).toContain('console.log("Hello");'); 111 | expect(result.content).not.toContain('file2.md ====='); 112 | expect(result.content).not.toContain('# Markdown'); 113 | expect(result.content).not.toContain('file3.yml ====='); 114 | expect(result.content).not.toContain('key: value'); 115 | expect(result.content).not.toContain('===== subdir/file4.js ====='); 116 | expect(result.content).not.toContain('===== subdir/file5.md ====='); // Changed this line 117 | expect(result.content).not.toContain('## Subheading'); 118 | expect(result.content).toContain('End.'); 119 | }); 120 | 121 | test('processes a prompt file with wildcard ignore patterns', async () => { 122 | const promptContent = 'Files:\n{{@.:-**/*dir/**,-*.y*}}\nEnd.'; 123 | const promptFile = path.join(testDir, 'prompt.txt'); 124 | await fs.writeFile(promptFile, promptContent); 125 | 126 | const result = await processPromptFile(promptFile); 127 | 128 | expect(result.content).toContain('Files:'); 129 | expect(result.content).toContain('file1.js ====='); 130 | expect(result.content).toContain('console.log("Hello");'); 131 | expect(result.content).toContain('file2.md ====='); 132 | expect(result.content).toContain('# Markdown'); 133 | expect(result.content).not.toContain('file3.yml ====='); 134 | expect(result.content).not.toContain('key: value'); 135 | expect(result.content).not.toContain('===== subdir/'); 136 | expect(result.content).toContain('End.'); 137 | }); 138 | 139 | test('handles empty directories', async () => { 140 | await fs.mkdir(path.join(testDir, 'emptyDir')); 141 | const files = await filterFiles({}, testDir, testDir); 142 | expect(files).not.toContain('emptyDir'); 143 | }); 144 | 145 | test('handles files with special characters', async () => { 146 | await fs.writeFile(path.join(testDir, 'file-with-dashes.js'), 'content'); 147 | await fs.writeFile(path.join(testDir, 'file with spaces.js'), 'content'); 148 | await fs.writeFile(path.join(testDir, 'file_with_underscores.js'), 'content'); 149 | const files = (await filterFiles({}, testDir, testDir))!.map(cleanPath); 150 | expect(files).toContain('file-with-dashes.js'); 151 | expect(files).toContain('file with spaces.js'); 152 | expect(files).toContain('file_with_underscores.js'); 153 | }); 154 | 155 | test('handles very long file paths', async () => { 156 | const longPath = 'a'.repeat(200); 157 | await fs.mkdir(path.join(testDir, longPath), {recursive: true}); 158 | await fs.writeFile(path.join(testDir, longPath, 'longfile.js'), 'content'); 159 | const files = (await filterFiles({}, testDir, testDir))!.map(cleanPath); 160 | expect(files).toContain(path.join(longPath, 'longfile.js')); 161 | }); 162 | 163 | test('handles case sensitivity in file extensions', async () => { 164 | await fs.writeFile(path.join(testDir, 'upper.JS'), 'content'); 165 | await fs.writeFile(path.join(testDir, 'mixed.Js'), 'content'); 166 | const files = (await filterFiles({exclude: 'js'}, testDir, testDir))!.map(cleanPath); 167 | expect(files).toContain('upper.JS'); 168 | expect(files).toContain('mixed.Js'); 169 | }); 170 | 171 | test('handles multiple exclusion patterns', async () => { 172 | const files = await filterFiles({exclude: '.js,**/subdir/**.txt'}, testDir, testDir); 173 | expect(files?.length).toBe(1); 174 | expect(files?.map(cleanPath).sort()).toEqual(['file2.md'].sort()); 175 | }); 176 | 177 | test('handles symlinks', async () => { 178 | await fs.symlink(path.join(testDir, 'file1.js'), path.join(testDir, 'symlink.js')); 179 | const files = (await filterFiles({}, testDir, testDir))!.map(cleanPath); 180 | expect(files).toContain('symlink.js'); 181 | }); 182 | 183 | test('excludes files by full path', async () => { 184 | const files = (await filterFiles({exclude: 'subdir/file4.js'}, testDir, testDir))!.map(cleanPath); 185 | expect(files).not.toContain(path.join('subdir', 'file4.js')); 186 | expect(files).toContain(path.join('subdir', 'file3.txt')); 187 | }); 188 | 189 | test('handles nested exclusions', async () => { 190 | await fs.mkdir(path.join(testDir, 'nested', 'dir'), {recursive: true}); 191 | await fs.writeFile(path.join(testDir, 'nested', 'file.js'), 'content'); 192 | await fs.writeFile(path.join(testDir, 'nested', 'dir', 'file.js'), 'content'); 193 | const files = (await filterFiles({exclude: '**/nested/*.js'}, testDir, testDir))!.map(cleanPath); 194 | expect(files).not.toContain(path.join('nested', 'file.js')); 195 | expect(files).toContain(path.join('nested', 'dir', 'file.js')); 196 | }); 197 | 198 | test('excludes all files of a type regardless of location', async () => { 199 | const files = (await filterFiles({exclude: '**/*.md'}, testDir, testDir))!.map(cleanPath); 200 | expect(files).not.toContain('file2.md'); 201 | expect(files).not.toContain(path.join('subdir', 'file5.md')); 202 | }); 203 | 204 | test('excludes hidden folder and its files with glob pattern', async () => { 205 | const files = (await filterFiles({exclude: '.*'}, testDir, testDir))!.map(cleanPath); 206 | expect(files?.length).toBe(3); 207 | expect(files?.sort()).toEqual([ 208 | 'file1.js', 209 | 'file2.md', 210 | path.join('subdir', 'file3.txt') 211 | ].sort()); 212 | }); 213 | 214 | test('processes a prompt file with single file reference inside fenced block', async () => { 215 | const promptContent = 'This is a test prompt.\n{{{ {{@file1.js}} {{@file1.js}} }}}End of prompt.'; 216 | const promptFile = path.join(testDir, 'prompt.txt'); 217 | await fs.writeFile(promptFile, promptContent); 218 | 219 | const result = await processPromptFile(promptFile); 220 | 221 | expect(result.content).toContain('This is a test prompt.'); 222 | expect(result.content).toContain('```'); 223 | expect(result.content).toContain('file1.js ====='); 224 | expect(result.content).toContain('console.log("Hello");'); 225 | expect(result.content).toContain('End of prompt.'); 226 | }); 227 | 228 | test('processes a prompt file with single file reference inside auto-fenced block', async () => { 229 | const promptContent = 'This is a test prompt.\n {{{@file1.js}}} End of prompt.'; 230 | const promptFile = path.join(testDir, 'prompt.txt'); 231 | await fs.writeFile(promptFile, promptContent); 232 | 233 | const result = await processPromptFile(promptFile); 234 | 235 | expect(result.content).toContain('This is a test prompt.'); 236 | expect(result.content).toContain('```'); 237 | expect(result.content).toContain('file1.js ====='); 238 | expect(result.content).toContain('console.log("Hello");'); 239 | expect(result.content).toContain('End of prompt.'); 240 | }); 241 | 242 | test('processes a prompt file with single file reference inside auto-fenced block with spaces', async () => { 243 | const promptContent = 'This is a test prompt.\n {{{ @file1.js }}} End of prompt.'; 244 | const promptFile = path.join(testDir, 'prompt.txt'); 245 | await fs.writeFile(promptFile, promptContent); 246 | 247 | const result = await processPromptFile(promptFile); 248 | 249 | expect(result.content).toContain('This is a test prompt.'); 250 | expect(result.content).toContain('```'); 251 | expect(result.content).toContain('file1.js ====='); 252 | expect(result.content).toContain('console.log("Hello");'); 253 | expect(result.content).toContain('End of prompt.'); 254 | }); 255 | 256 | describe('Inclusion Patterns (+)', () => { 257 | test('processes a prompt file with a single inclusion pattern', async () => { 258 | const promptContent = 'JS files only:\n{{@.:+*.js}}\nEnd.'; 259 | const promptFile = path.join(testDir, 'prompt.txt'); 260 | await fs.writeFile(promptFile, promptContent); 261 | 262 | const result = await processPromptFile(promptFile); 263 | 264 | expect(result.content).toContain('JS files only:'); 265 | // Should include the JS file 266 | expect(result.content).toContain('file1.js ====='); 267 | expect(result.content).toContain('console.log("Hello");'); 268 | // Should NOT include other files 269 | expect(result.content).not.toContain('file2.md ====='); 270 | expect(result.content).not.toContain('subdir/file3.txt ====='); 271 | expect(result.content).toContain('End.'); 272 | }); 273 | 274 | test('processes a prompt file with multiple inclusion patterns', async () => { 275 | const promptContent = 'JS and MD files:\n{{@.:+*.js,+*.md}}\nEnd.'; 276 | const promptFile = path.join(testDir, 'prompt.txt'); 277 | await fs.writeFile(promptFile, promptContent); 278 | 279 | const result = await processPromptFile(promptFile); 280 | 281 | expect(result.content).toContain('JS and MD files:'); 282 | // Should include JS and MD files 283 | expect(result.content).toContain('file1.js ====='); 284 | expect(result.content).toContain('file2.md ====='); 285 | // Should NOT include the txt file in the subdirectory 286 | expect(result.content).not.toContain('subdir/file3.txt ====='); 287 | expect(result.content).toContain('End.'); 288 | }); 289 | 290 | test('combines inclusion and exclusion patterns correctly', async () => { 291 | // Let's add another JS file to make the test more robust 292 | await fs.writeFile(path.join(testDir, 'subdir', 'another.js'), 'another js file'); 293 | 294 | // This should first select all files in 'subdir', then remove the .txt file. 295 | const promptContent = 'Subdir files except .txt:\n{{@.:+**/subdir/**,-*.txt}}\nEnd.'; 296 | const promptFile = path.join(testDir, 'prompt.txt'); 297 | await fs.writeFile(promptFile, promptContent); 298 | 299 | const result = await processPromptFile(promptFile); 300 | 301 | expect(result.content).toContain('Subdir files except .txt:'); 302 | // Should include the new JS file in the subdir 303 | expect(result.content).toContain('subdir/another.js ====='); 304 | // Should EXCLUDE the txt file due to the negative pattern 305 | expect(result.content).not.toContain('subdir/file3.txt ====='); 306 | // Should also exclude files not matching the inclusion pattern 307 | expect(result.content).not.toContain('file1.js ====='); 308 | expect(result.content).toContain('End.'); 309 | }); 310 | 311 | test('handles inclusion patterns that match no files', async () => { 312 | const promptContent = 'No files here:\n{{@.:+*.nonexistent,+*.imaginary}}\nEnd.'; 313 | const promptFile = path.join(testDir, 'prompt.txt'); 314 | await fs.writeFile(promptFile, promptContent); 315 | 316 | const result = await processPromptFile(promptFile); 317 | 318 | expect(result.content).toContain('No files here:'); 319 | expect(result.content).toContain('End.'); 320 | // Should not contain any file headers 321 | expect(result.content).not.toContain('====='); 322 | // Should have one warning `Warning: Error processing placeholder` 323 | expect(result.warnings).toHaveLength(1); 324 | }); 325 | }) 326 | 327 | 328 | }); 329 | 330 | 331 | describe('Directory Tree Feature', () => { 332 | let testDir: string; 333 | 334 | beforeEach(async () => { 335 | testDir = await fs.mkdtemp(path.join(os.tmpdir(), 'copa-dir-tree-test-')); 336 | 337 | // Create a sample directory structure 338 | await fs.mkdir(path.join(testDir, 'src')); 339 | await fs.mkdir(path.join(testDir, 'src', 'components')); 340 | await fs.mkdir(path.join(testDir, 'src', 'utils')); 341 | await fs.mkdir(path.join(testDir, 'docs')); 342 | 343 | // Create some files 344 | await fs.writeFile(path.join(testDir, 'src', 'index.js'), 'console.log("root");'); 345 | await fs.writeFile(path.join(testDir, 'src', 'components', 'Button.js'), 'class Button {}'); 346 | await fs.writeFile(path.join(testDir, 'src', 'components', 'Card.js'), 'class Card {}'); 347 | await fs.writeFile(path.join(testDir, 'src', 'utils', 'format.js'), 'function format() {}'); 348 | await fs.writeFile(path.join(testDir, 'docs', 'README.md'), '# Documentation'); 349 | await fs.writeFile(path.join(testDir, '.gitignore'), 'node_modules'); 350 | }); 351 | 352 | afterEach(async () => { 353 | await fs.rm(testDir, {recursive: true, force: true}); 354 | }); 355 | 356 | test('generates a directory tree for a given path', async () => { 357 | const promptContent = 'Project structure:\n{{@src:dir}}\n\nDocs structure:\n{{@docs:dir}}'; 358 | const promptFile = path.join(testDir, 'prompt.txt'); 359 | await fs.writeFile(promptFile, promptContent); 360 | 361 | const result = await processPromptFile(promptFile); 362 | 363 | // Check for main project structure 364 | expect(result.content).toContain('Project structure:'); 365 | expect(result.content).toContain('===== Directory Structure: src ====='); 366 | expect(result.content).toContain('src'); 367 | expect(result.content).toContain('├── components/'); 368 | expect(result.content).toContain('│ ├── Button.js'); 369 | expect(result.content).toContain('│ └── Card.js'); 370 | expect(result.content).toContain('├── utils/'); 371 | expect(result.content).toContain('│ └── format.js'); 372 | expect(result.content).toContain('└── index.js'); 373 | 374 | // Check for docs structure 375 | expect(result.content).toContain('Docs structure:'); 376 | expect(result.content).toContain('===== Directory Structure: docs ====='); 377 | expect(result.content).toContain('docs'); 378 | expect(result.content).toContain('└── README.md'); 379 | }); 380 | 381 | test('applies ignore patterns to directory tree', async () => { 382 | const promptContent = 'Project structure with ignore:\n{{@src:dir,*.js}}\n'; 383 | const promptFile = path.join(testDir, 'prompt.txt'); 384 | await fs.writeFile(promptFile, promptContent); 385 | 386 | const result = await processPromptFile(promptFile); 387 | 388 | // Should show directories but not JS files 389 | expect(result.content).toContain('Project structure with ignore:'); 390 | expect(result.content).toContain('===== Directory Structure: src ====='); 391 | expect(result.content).toContain('src'); 392 | // empty dirs 393 | expect(result.content).not.toContain('├── components/'); 394 | expect(result.content).not.toContain('└── utils/'); 395 | 396 | // JS files should not be included 397 | expect(result.content).not.toContain('Button.js'); 398 | expect(result.content).not.toContain('Card.js'); 399 | expect(result.content).not.toContain('format.js'); 400 | expect(result.content).not.toContain('index.js'); 401 | }); 402 | 403 | test('handles nested directory references correctly', async () => { 404 | const promptContent = 'Components:\n{{@src/components:dir}}\n'; 405 | const promptFile = path.join(testDir, 'prompt.txt'); 406 | await fs.writeFile(promptFile, promptContent); 407 | 408 | const result = await processPromptFile(promptFile); 409 | 410 | expect(result.content).toContain('Components:'); 411 | expect(result.content).toContain('===== Directory Structure: src/components ====='); 412 | expect(result.content).toContain('components'); 413 | expect(result.content).toContain('├── Button.js'); 414 | expect(result.content).toContain('└── Card.js'); 415 | }); 416 | 417 | test('combines dir option with file content references', async () => { 418 | const promptContent = 'Structure:\n{{@src:dir}}\n\nContent:\n{{@src/index.js}}'; 419 | const promptFile = path.join(testDir, 'prompt.txt'); 420 | await fs.writeFile(promptFile, promptContent); 421 | 422 | const result = await processPromptFile(promptFile); 423 | 424 | // Should have both the directory structure and file content 425 | expect(result.content).toContain('Structure:'); 426 | expect(result.content).toContain('===== Directory Structure: src ====='); 427 | expect(result.content).toContain('src'); 428 | 429 | expect(result.content).toContain('Content:'); 430 | expect(result.content).toContain('===== src/index.js ====='); 431 | expect(result.content).toContain('console.log("root");'); 432 | }); 433 | 434 | test('applies inclusion patterns to directory tree', async () => { 435 | const promptContent = 'JS project structure:\n{{@src:dir,+**/*.js}}'; 436 | const promptFile = path.join(testDir, 'prompt.txt'); 437 | await fs.writeFile(promptFile, promptContent); 438 | 439 | const result = await processPromptFile(promptFile); 440 | 441 | expect(result.content).toContain('JS project structure:'); 442 | expect(result.content).toContain('===== Directory Structure: src ====='); 443 | 444 | // It should show all the JS files 445 | expect(result.content).toContain('Button.js'); 446 | expect(result.content).toContain('Card.js'); 447 | expect(result.content).toContain('format.js'); 448 | expect(result.content).toContain('index.js'); 449 | 450 | // Let's add a non-matching file to be sure. 451 | await fs.writeFile(path.join(testDir, 'src', 'config.json'), '{}'); 452 | const secondResult = await processPromptFile(promptFile); 453 | 454 | expect(secondResult.content).not.toContain('config.json'); 455 | }); 456 | 457 | test('correctly applies inclusion glob on a subdirectory for dir tree', async () => { 458 | await fs.mkdir(path.join(testDir, 'packages', 'cli'), {recursive: true}); 459 | await fs.mkdir(path.join(testDir, 'packages', 'frontend'), {recursive: true}); 460 | 461 | await fs.writeFile(path.join(testDir, 'packages', 'cli', 'tsup.config.ts'), '// tsup config'); 462 | await fs.writeFile(path.join(testDir, 'packages', 'frontend', 'vite.config.ts'), '// vite config'); 463 | 464 | await fs.writeFile(path.join(testDir, 'packages', 'cli', 'index.js'), '// main file'); 465 | 466 | await fs.writeFile(path.join(testDir, 'root.config.ts'), '// root config'); 467 | 468 | 469 | await exec('git init', { cwd: testDir }); 470 | await exec('git add .', { cwd: testDir }); 471 | 472 | const promptFile = path.join(testDir, 'prompt.txt'); 473 | await fs.writeFile(promptFile, 'Package configs:\n{{@packages:dir,+*.config.ts}}'); 474 | const result1 = await processPromptFile(promptFile); 475 | await fs.writeFile(promptFile, 'Package configs:\n{{@./:dir,+*.config.ts}}'); 476 | const result2 = await processPromptFile(promptFile); 477 | 478 | expect(result1.content).toContain('Package configs:'); 479 | expect(result1.content).toContain('===== Directory Structure: packages ====='); 480 | expect(result1.content).toContain('packages'); 481 | expect(result1.content).toContain('├── cli/'); 482 | expect(result1.content).toContain('│ └── tsup.config.ts'); 483 | expect(result1.content).toContain('└── frontend/'); 484 | expect(result1.content).toContain(' └── vite.config.ts'); 485 | 486 | expect(result1.content).not.toContain('index.js'); 487 | 488 | expect(result1.content).not.toContain('root.config.ts'); 489 | }) 490 | }) 491 | --------------------------------------------------------------------------------