From f319ddef002fd30722ca991377b4b9ce6683d46a Mon Sep 17 00:00:00 2001 From: "Thibault YOU (aider)" Date: Wed, 23 Oct 2024 09:53:50 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=85=20test:=20Add=20comprehensive=20test?= =?UTF-8?q?=20suite=20for=20codebase?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.test | 5 + .gitignore | 4 + README.md | 25 +- jest.config.js | 23 +- jest.setup.ts | 10 + package-lock.json | 140 +++- package.json | 19 +- .../README.md | 1 - .../metadata.yml | 3 - .../config/{app.config.ts => app-config.ts} | 8 +- .../__tests__/update-metadata.test.ts | 273 ++++++++ .../__tests__/update-views.test.ts | 121 ++++ .../update-metadata.ts} | 45 +- .../update-views.ts} | 26 +- .../{main_readme.md => main-readme.md} | 0 .../{sub_readme.md => sub-readme.md} | 0 .../utils/__tests__/analyze-prompt.test.ts | 61 ++ .../utils/__tests__/fragment-manager.test.ts | 77 +++ .../__tests__/metadata-generator.test.ts | 114 ++++ .../__tests__/prompt-analyzer-cli.test.ts | 83 +++ .../utils/__tests__/yaml-operations.test.ts | 203 ++++++ src/app/utils/analyze-prompt.ts | 15 + ...nt_manager.util.ts => fragment-manager.ts} | 6 +- ...analyzer.util.ts => metadata-generator.ts} | 53 +- src/app/utils/prompt-analyzer-cli.ts | 26 + ..._operations.util.ts => yaml-operations.ts} | 82 +-- .../{base.command.ts => base-command.ts} | 8 +- .../{config.command.ts => config-command.ts} | 2 +- .../{env.command.ts => env-command.ts} | 16 +- ...{execute.command.ts => execute-command.ts} | 33 +- .../{flush.command.ts => flush-command.ts} | 4 +- ...gments.command.ts => fragments-command.ts} | 6 +- .../{menu.command.ts => menu-command.ts} | 6 +- ...{prompts.command.ts => prompts-command.ts} | 20 +- ...ettings.command.ts => settings-command.ts} | 8 +- .../{sync.command.ts => sync-command.ts} | 8 +- .../{cli.config.ts => config/cli-config.ts} | 6 +- src/cli/{cli.constants.ts => constants.ts} | 0 src/cli/index.ts | 20 +- .../__snapshots__/prompts.test.ts.snap | 79 +++ .../__tests__/conversation-manager.test.ts | 94 +++ src/cli/utils/__tests__/database.test.ts | 634 ++++++++++++++++++ src/cli/utils/__tests__/env-vars.test.ts | 178 +++++ src/cli/utils/__tests__/errors.test.ts | 76 +++ src/cli/utils/__tests__/file-system.test.ts | 77 +++ src/cli/utils/__tests__/fragments.test.ts | 84 +++ .../utils/__tests__/input-resolver.test.ts | 122 ++++ src/cli/utils/__tests__/prompts.test.ts | 453 +++++++++++++ ...anager.util.ts => conversation-manager.ts} | 8 +- .../utils/{database.util.ts => database.ts} | 32 +- src/cli/utils/{env.util.ts => env-vars.ts} | 4 +- src/cli/utils/{error.util.ts => errors.ts} | 2 +- .../{file_system.util.ts => file-system.ts} | 6 +- ...agment_operations.util.ts => fragments.ts} | 6 +- ...t_resolution.util.ts => input-resolver.ts} | 17 +- src/cli/utils/metadata.util.ts | 52 -- src/cli/utils/prompt_crud.util.ts | 93 --- src/cli/utils/prompt_display.util.ts | 76 --- src/cli/utils/prompts.ts | 235 +++++++ src/shared/config/__tests__/config.test.ts | 213 ++++++ .../{common.config.ts => common-config.ts} | 1 - .../{config.constants.ts => constants.ts} | 4 +- src/shared/config/index.ts | 100 +-- src/shared/types/index.ts | 14 +- .../utils/__tests__/anthropic-client.test.ts | 175 +++++ .../utils/__tests__/file-system.test.ts | 224 +++++++ src/shared/utils/__tests__/logger.test.ts | 93 +++ .../utils/__tests__/prompt-processing.test.ts | 252 +++++++ .../utils/__tests__/string-formatter.test.ts | 23 + ...pic_client.util.ts => anthropic-client.ts} | 4 +- .../{file_system.util.ts => file-system.ts} | 5 +- .../utils/{logger.util.ts => logger.ts} | 7 +- ...rocessing.util.ts => prompt-processing.ts} | 33 +- ..._formatter.util.ts => string-formatter.ts} | 10 +- tests/.gitkeep | 0 tsconfig.json | 2 +- tsconfig.test.json | 7 +- 77 files changed, 4473 insertions(+), 582 deletions(-) create mode 100644 .env.test create mode 100644 jest.setup.ts rename src/app/config/{app.config.ts => app-config.ts} (81%) create mode 100644 src/app/controllers/__tests__/update-metadata.test.ts create mode 100644 src/app/controllers/__tests__/update-views.test.ts rename src/app/{core/update_metadata.ts => controllers/update-metadata.ts} (88%) rename src/app/{core/update_views.ts => controllers/update-views.ts} (82%) rename src/app/templates/{main_readme.md => main-readme.md} (100%) rename src/app/templates/{sub_readme.md => sub-readme.md} (100%) create mode 100644 src/app/utils/__tests__/analyze-prompt.test.ts create mode 100644 src/app/utils/__tests__/fragment-manager.test.ts create mode 100644 src/app/utils/__tests__/metadata-generator.test.ts create mode 100644 src/app/utils/__tests__/prompt-analyzer-cli.test.ts create mode 100644 src/app/utils/__tests__/yaml-operations.test.ts create mode 100644 src/app/utils/analyze-prompt.ts rename src/app/utils/{fragment_manager.util.ts => fragment-manager.ts} (90%) rename src/app/utils/{prompt_analyzer.util.ts => metadata-generator.ts} (66%) create mode 100644 src/app/utils/prompt-analyzer-cli.ts rename src/app/utils/{yaml_operations.util.ts => yaml-operations.ts} (63%) rename src/cli/commands/{base.command.ts => base-command.ts} (95%) rename src/cli/commands/{config.command.ts => config-command.ts} (98%) rename src/cli/commands/{env.command.ts => env-command.ts} (96%) rename src/cli/commands/{execute.command.ts => execute-command.ts} (87%) rename src/cli/commands/{flush.command.ts => flush-command.ts} (92%) rename src/cli/commands/{fragments.command.ts => fragments-command.ts} (96%) rename src/cli/commands/{menu.command.ts => menu-command.ts} (93%) rename src/cli/commands/{prompts.command.ts => prompts-command.ts} (96%) rename src/cli/commands/{settings.command.ts => settings-command.ts} (89%) rename src/cli/commands/{sync.command.ts => sync-command.ts} (97%) rename src/cli/{cli.config.ts => config/cli-config.ts} (71%) rename src/cli/{cli.constants.ts => constants.ts} (100%) create mode 100644 src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap create mode 100644 src/cli/utils/__tests__/conversation-manager.test.ts create mode 100644 src/cli/utils/__tests__/database.test.ts create mode 100644 src/cli/utils/__tests__/env-vars.test.ts create mode 100644 src/cli/utils/__tests__/errors.test.ts create mode 100644 src/cli/utils/__tests__/file-system.test.ts create mode 100644 src/cli/utils/__tests__/fragments.test.ts create mode 100644 src/cli/utils/__tests__/input-resolver.test.ts create mode 100644 src/cli/utils/__tests__/prompts.test.ts rename src/cli/utils/{conversation_manager.util.ts => conversation-manager.ts} (92%) rename src/cli/utils/{database.util.ts => database.ts} (93%) rename src/cli/utils/{env.util.ts => env-vars.ts} (96%) rename src/cli/utils/{error.util.ts => errors.ts} (96%) rename src/cli/utils/{file_system.util.ts => file-system.ts} (82%) rename src/cli/utils/{fragment_operations.util.ts => fragments.ts} (93%) rename src/cli/utils/{input_resolution.util.ts => input-resolver.ts} (77%) delete mode 100644 src/cli/utils/metadata.util.ts delete mode 100644 src/cli/utils/prompt_crud.util.ts delete mode 100644 src/cli/utils/prompt_display.util.ts create mode 100644 src/cli/utils/prompts.ts create mode 100644 src/shared/config/__tests__/config.test.ts rename src/shared/config/{common.config.ts => common-config.ts} (95%) rename src/shared/config/{config.constants.ts => constants.ts} (69%) create mode 100644 src/shared/utils/__tests__/anthropic-client.test.ts create mode 100644 src/shared/utils/__tests__/file-system.test.ts create mode 100644 src/shared/utils/__tests__/logger.test.ts create mode 100644 src/shared/utils/__tests__/prompt-processing.test.ts create mode 100644 src/shared/utils/__tests__/string-formatter.test.ts rename src/shared/utils/{anthropic_client.util.ts => anthropic-client.ts} (94%) rename src/shared/utils/{file_system.util.ts => file-system.ts} (93%) rename src/shared/utils/{logger.util.ts => logger.ts} (86%) rename src/shared/utils/{prompt_processing.util.ts => prompt-processing.ts} (71%) rename src/shared/utils/{string_formatter.util.ts => string-formatter.ts} (62%) delete mode 100644 tests/.gitkeep diff --git a/.env.test b/.env.test new file mode 100644 index 0000000..d440e73 --- /dev/null +++ b/.env.test @@ -0,0 +1,5 @@ +ANTHROPIC_API_KEY=test-anthropic-key +FORCE_REGENERATE=false +CLI_ENV=cli +NODE_ENV=test +LOG_LEVEL=error \ No newline at end of file diff --git a/.gitignore b/.gitignore index 7f13a33..defb0e9 100644 --- a/.gitignore +++ b/.gitignore @@ -5,10 +5,14 @@ dist/ node_modules/ archive/ +coverage/ # Ignore local database *.sqlite +# Ignore aider files +.aider* + # Ignore macOS files .DS_Store diff --git a/README.md b/README.md index a0d7918..6f4378d 100644 --- a/README.md +++ b/README.md @@ -6,25 +6,8 @@ Welcome to the **Prompt Library**, a collection of categorized AI prompts for ea ## 📚 Table of Contents - - - -- [🎯 Purpose & Features](#-purpose--features) -- [⚡ Quick Start](#-quick-start) -- [🛠️ How It Works](#-how-it-works) -- [🖥️ CLI Usage](#-cli-usage) - - [Interactive Menu](#interactive-menu) - - [List Prompts and Categories](#list-prompts-and-categories) - - [Sync Personal Library](#sync-personal-library) - - [Execute Prompts](#execute-prompts) -- [📂 Prompt Library Example](#-prompt-library-example) -- [🚀 Getting Started](#-getting-started) -- [🧩 Using Fragments](#-using-fragments) -- [⚙️ Metadata Customization](#-metadata-customization) -- [🤝 Contributing](#-contributing) -- [📄 License](#-license) - - + + ## 🎯 Purpose & Features @@ -128,9 +111,9 @@ prompt-library-cli execute --help - [Git Branch Name Generator](prompts/git_branch_name_generator/README.md) - Generates optimized git branch names based on project context and user requirements - [Git Commit Message Agent](prompts/git_commit_message_agent/README.md) - Generates precise and informative git commit messages following Conventional Commits specification - [GitHub Issue Creator](prompts/github_issue_creator_agent/README.md) - Creates comprehensive and actionable GitHub issues based on provided project information -- [Software Architect Visionary](prompts/software_architect_agent/README.md) - Analyzes user requirements and creates comprehensive software specification documents - [Software Architect Code Reviewer](prompts/software_architect_code_reviewer/README.md) - Generates comprehensive pull requests with architectural analysis and optimization suggestions - [Software Architect Specification Creator](prompts/software_architect_spec_creator/README.md) - Creates comprehensive software specification documents based on user requirements +- [Software Architect Visionary](prompts/software_architect_agent/README.md) - Analyzes user requirements and creates comprehensive software specification documents - [Software Development Expert Agent](prompts/software_dev_expert_agent/README.md) - Provides expert, adaptive assistance across all aspects of the software development lifecycle. @@ -143,8 +126,8 @@ prompt-library-cli execute --help
Healthcare -- [Psychological Support and Therapy Agent](prompts/psychological_support_agent/README.md) - Provides AI-driven psychological support and therapy through digital platforms - [Health Optimization Agent](prompts/health_optimization_agent/README.md) - Generates personalized, adaptive health optimization plans based on comprehensive user data analysis +- [Psychological Support and Therapy Agent](prompts/psychological_support_agent/README.md) - Provides AI-driven psychological support and therapy through digital platforms
diff --git a/jest.config.js b/jest.config.js index c924a13..2044cd6 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,10 +1,21 @@ module.exports = { preset: 'ts-jest', testEnvironment: 'node', - testMatch: ['**/tests/**/*.test.ts'], - globals: { - 'ts-jest': { + setupFiles: ['/jest.setup.ts'], + testMatch: ['/src/**/__tests__/**/*.test.ts'], + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + moduleNameMapper: { + '^@/(.*)$': '/src/$1' + }, + transform: { + '^.+\\.ts?$': ['ts-jest', { tsconfig: 'tsconfig.test.json' - } - } -}; + }] + }, + collectCoverage: true, + coverageDirectory: 'coverage', + coveragePathIgnorePatterns: [ + '/node_modules/', + '/dist/' + ] +}; \ No newline at end of file diff --git a/jest.setup.ts b/jest.setup.ts new file mode 100644 index 0000000..2a7040e --- /dev/null +++ b/jest.setup.ts @@ -0,0 +1,10 @@ +import * as path from 'path'; +import dotenv from 'dotenv'; + +const originalEnv = { ...process.env }; +const envTestPath = path.resolve(__dirname, '.env.test'); +dotenv.config({ path: envTestPath }); + +process.env.NODE_ENV = 'test'; + +export { originalEnv }; diff --git a/package-lock.json b/package-lock.json index e5191c8..3885e7e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,11 +26,13 @@ }, "devDependencies": { "@eslint/compat": "1.2.0", + "@jest/globals": "^29.7.0", + "@testing-library/jest-dom": "^6.4.2", "@types/fs-extra": "11.0.4", "@types/inquirer": "9.0.7", - "@types/jest": "29.5.13", + "@types/jest": "^29.5.14", "@types/js-yaml": "4.0.9", - "@types/node": "22.7.6", + "@types/node": "^22.7.6", "@types/node-cache": "4.2.5", "@types/nunjucks": "3.2.6", "@types/sqlite3": "3.1.11", @@ -43,15 +45,24 @@ "eslint-plugin-prettier": "5.2.1", "eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-unused-imports": "4.1.4", - "jest": "29.7.0", + "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "mock-fs": "^5.2.0", "npm-check-updates": "17.1.4", "prettier": "3.3.3", - "ts-jest": "29.2.5", + "ts-jest": "^29.2.5", "ts-node": "10.9.2", "typescript": "5.6.3", "yaml-lint": "1.7.0" } }, + "node_modules/@adobe/css-tools": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.4.0.tgz", + "integrity": "sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@ampproject/remapping": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", @@ -1747,6 +1758,41 @@ "@sinonjs/commons": "^3.0.0" } }, + "node_modules/@testing-library/jest-dom": { + "version": "6.6.2", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.2.tgz", + "integrity": "sha512-P6GJD4yqc9jZLbe98j/EkyQDTPgqftohZF5FBkHY5BUERZmcf4HeO2k0XaefEg329ux2p21i1A1DmyQ1kKw2Jw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "chalk": "^3.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "lodash": "^4.17.21", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/chalk": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-3.0.0.tgz", + "integrity": "sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/@textlint/ast-node-types": { "version": "12.6.1", "resolved": "https://registry.npmjs.org/@textlint/ast-node-types/-/ast-node-types-12.6.1.tgz", @@ -1922,9 +1968,9 @@ } }, "node_modules/@types/jest": { - "version": "29.5.13", - "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.13.tgz", - "integrity": "sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg==", + "version": "29.5.14", + "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.14.tgz", + "integrity": "sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ==", "dev": true, "license": "MIT", "dependencies": { @@ -2496,6 +2542,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/array-buffer-byte-length": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", @@ -3367,6 +3423,13 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -3616,6 +3679,13 @@ "doctoc": "doctoc.js" } }, + "node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, "node_modules/dom-serializer": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-1.4.1.tgz", @@ -5289,8 +5359,8 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "devOptional": true, "license": "MIT", - "optional": true, "engines": { "node": ">=8" } @@ -6580,6 +6650,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/lodash": { + "version": "4.17.21", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.memoize": { "version": "4.1.2", "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", @@ -7089,6 +7166,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7236,6 +7323,16 @@ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==", "license": "MIT" }, + "node_modules/mock-fs": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/mock-fs/-/mock-fs-5.4.0.tgz", + "integrity": "sha512-3ROPnEMgBOkusBMYQUW2rnT3wZwsgfOKzJDLvx/TZ7FL1WmWvwSwn3j4aDR5fLDGtgcc1WF0Z1y0di7c9L4FKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", @@ -8229,6 +8326,20 @@ "node": ">= 6" } }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/regexp.prototype.flags": { "version": "1.5.3", "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.3.tgz", @@ -8959,6 +9070,19 @@ "node": ">=6" } }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", diff --git a/package.json b/package.json index e7e8b5a..39e1067 100644 --- a/package.json +++ b/package.json @@ -16,13 +16,14 @@ "lint:fix": "npm run lint -- --fix", "prettify": "prettier --write 'src/**/*.ts'", "start": "node dist/cli/index.js", - "test": "jest --passWithNoTests", + "test": "jest", "test:watch": "jest --watch", + "test:coverage": "jest --coverage", "toc": "doctoc README.md --github --notitle", "type-check": "tsc --noEmit", "update": "ncu -i", - "update-metadata": "ts-node src/app/core/update_metadata.ts", - "update-views": "ts-node src/app/core/update_views.ts", + "update-metadata": "ts-node src/app/controllers/update-metadata.ts", + "update-views": "ts-node src/app/controllers/update-views.ts", "validate-yaml": "yamllint '**/*.yml'" }, "keywords": [ @@ -56,11 +57,13 @@ }, "devDependencies": { "@eslint/compat": "1.2.0", + "@jest/globals": "^29.7.0", + "@testing-library/jest-dom": "^6.4.2", "@types/fs-extra": "11.0.4", "@types/inquirer": "9.0.7", - "@types/jest": "29.5.13", + "@types/jest": "^29.5.14", "@types/js-yaml": "4.0.9", - "@types/node": "22.7.6", + "@types/node": "^22.7.6", "@types/node-cache": "4.2.5", "@types/nunjucks": "3.2.6", "@types/sqlite3": "3.1.11", @@ -73,10 +76,12 @@ "eslint-plugin-prettier": "5.2.1", "eslint-plugin-simple-import-sort": "12.1.1", "eslint-plugin-unused-imports": "4.1.4", - "jest": "29.7.0", + "jest": "^29.7.0", + "jest-environment-node": "^29.7.0", + "mock-fs": "^5.2.0", "npm-check-updates": "17.1.4", "prettier": "3.3.3", - "ts-jest": "29.2.5", + "ts-jest": "^29.2.5", "ts-node": "10.9.2", "typescript": "5.6.3", "yaml-lint": "1.7.0" diff --git a/prompts/software_architect_code_reviewer/README.md b/prompts/software_architect_code_reviewer/README.md index 14d159e..2a6fbc1 100644 --- a/prompts/software_architect_code_reviewer/README.md +++ b/prompts/software_architect_code_reviewer/README.md @@ -19,7 +19,6 @@ This prompt simulates a world-class software architect and code reviewer, tasked ### 🧩 Relevant Fragments This prompt could potentially use the following fragments: -- [Prompt Engineering Guidelines Max](/fragments/prompt_engineering/prompt_engineering_guidelines_max.md) - Could be used into `{{EXTRA_GUIDELINES_OR_CONTEXT}}` - [Safety Guidelines](/fragments/prompt_engineering/safety_guidelines.md) - Could be used into `{{SAFETY_GUIDELINES}}` - [Behavior Attributes](/fragments/prompt_engineering/behavior_attributes.md) - Could be used into `{{AI_BEHAVIOR_ATTRIBUTES}}` diff --git a/prompts/software_architect_code_reviewer/metadata.yml b/prompts/software_architect_code_reviewer/metadata.yml index f33113b..960d243 100644 --- a/prompts/software_architect_code_reviewer/metadata.yml +++ b/prompts/software_architect_code_reviewer/metadata.yml @@ -6,9 +6,6 @@ description: >- innovative improvements to elevate entire codebases. directory: software_architect_code_reviewer fragments: - - category: prompt_engineering - name: prompt_engineering_guidelines_max - variable: '{{EXTRA_GUIDELINES_OR_CONTEXT}}' - category: prompt_engineering name: safety_guidelines variable: '{{SAFETY_GUIDELINES}}' diff --git a/src/app/config/app.config.ts b/src/app/config/app-config.ts similarity index 81% rename from src/app/config/app.config.ts rename to src/app/config/app-config.ts index 9e99b10..21e0a2b 100644 --- a/src/app/config/app.config.ts +++ b/src/app/config/app-config.ts @@ -10,7 +10,7 @@ export interface AppConfig { VIEW_TEMPLATE_NAME: string; README_TEMPLATE_NAME: string; DEFAULT_CATEGORY: string; - FORCE_REGENERATE: string; + FORCE_REGENERATE: boolean; YAML_INDENT: number; YAML_LINE_WIDTH: number; } @@ -22,10 +22,10 @@ export const appConfig: AppConfig = { ANALYZER_PROMPT_PATH: path.join('src', 'system_prompts', 'prompt_analysis_agent', 'prompt.md'), README_PATH: 'README.md', VIEW_FILE_NAME: 'README.md', - VIEW_TEMPLATE_NAME: 'sub_readme.md', - README_TEMPLATE_NAME: 'main_readme.md', + VIEW_TEMPLATE_NAME: 'sub-readme.md', + README_TEMPLATE_NAME: 'main-readme.md', DEFAULT_CATEGORY: 'uncategorized', - FORCE_REGENERATE: process.env.FORCE_REGENERATE ?? 'false', + FORCE_REGENERATE: process.env.FORCE_REGENERATE === 'true', YAML_INDENT: 2, YAML_LINE_WIDTH: 80 }; diff --git a/src/app/controllers/__tests__/update-metadata.test.ts b/src/app/controllers/__tests__/update-metadata.test.ts new file mode 100644 index 0000000..9eba02b --- /dev/null +++ b/src/app/controllers/__tests__/update-metadata.test.ts @@ -0,0 +1,273 @@ +import * as crypto from 'crypto'; +import * as path from 'path'; + +import { jest } from '@jest/globals'; + +import { commonConfig } from '../../../shared/config/common-config'; +import { PromptMetadata } from '../../../shared/types'; +import * as fileSystem from '../../../shared/utils/file-system'; +import logger from '../../../shared/utils/logger'; +import { appConfig } from '../../config/app-config'; +import * as promptAnalyzer from '../../utils/metadata-generator'; +import { generateMetadata, shouldUpdateMetadata, updateMetadataHash, updatePromptMetadata } from '../update-metadata'; + +jest.mock('../../../shared/utils/file-system'); +jest.mock('../../utils/metadata-generator'); +jest.mock('../../../shared/utils/logger'); +jest.mock('fs-extra'); + +describe('UpdateMetadataController', () => { + const mockPromptContent = 'Test prompt content'; + const mockMetadata: PromptMetadata = { + title: 'Test Prompt', + primary_category: 'Testing', + directory: 'test-prompt', + one_line_description: 'A test prompt', + description: 'Test description', + subcategories: ['unit-test'], + tags: ['test'], + variables: [] + }; + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(promptAnalyzer.processMetadataGeneration).mockResolvedValue(mockMetadata); + jest.mocked(fileSystem.readFileContent).mockResolvedValue(mockPromptContent); + jest.mocked(fileSystem.fileExists).mockResolvedValue(true); + jest.mocked(fileSystem.writeFileContent).mockResolvedValue(); + jest.mocked(fileSystem.createDirectory).mockResolvedValue(); + jest.mocked(fileSystem.isDirectory).mockResolvedValue(true); + jest.mocked(fileSystem.readDirectory).mockResolvedValue(['test-prompt']); + jest.mocked(fileSystem.renameFile).mockResolvedValue(); + jest.mocked(fileSystem.removeDirectory).mockResolvedValue(); + jest.mocked(fileSystem.isFile).mockResolvedValue(true); + jest.mocked(fileSystem.copyFile).mockResolvedValue(); + }); + + describe('generateMetadata', () => { + it('should generate metadata from prompt content', async () => { + const result = await generateMetadata(mockPromptContent); + expect(result).toEqual(mockMetadata); + expect(promptAnalyzer.processMetadataGeneration).toHaveBeenCalledWith(mockPromptContent); + }); + + it('should handle errors during metadata generation', async () => { + const error = new Error('Generation failed'); + jest.mocked(promptAnalyzer.processMetadataGeneration).mockRejectedValue(error); + + await expect(generateMetadata(mockPromptContent)).rejects.toThrow(error); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('shouldUpdateMetadata', () => { + const promptFile = 'test.md'; + const metadataFile = 'metadata.yml'; + beforeEach(() => { + jest.mocked(fileSystem.readFileContent).mockResolvedValue(mockPromptContent); + jest.mocked(fileSystem.fileExists).mockResolvedValue(true); + appConfig.FORCE_REGENERATE = false; + }); + + it('should return true when force regenerate is enabled', async () => { + appConfig.FORCE_REGENERATE = true; + const [shouldUpdate, _promptHash] = await shouldUpdateMetadata(promptFile, metadataFile); + expect(shouldUpdate).toBe(true); + }); + + it('should return true when metadata file does not exist', async () => { + jest.mocked(fileSystem.fileExists).mockResolvedValue(false); + const [shouldUpdate, _promptHash] = await shouldUpdateMetadata(promptFile, metadataFile); + expect(shouldUpdate).toBe(true); + }); + + it('should return true when content hash is missing', async () => { + jest.mocked(fileSystem.readFileContent).mockResolvedValue('no hash here'); + const [shouldUpdate, _promptHash] = await shouldUpdateMetadata(promptFile, metadataFile); + expect(shouldUpdate).toBe(true); + }); + + it('should return true when content hash differs', async () => { + const promptContent = 'prompt content'; + const promptHash = crypto.createHash('md5').update(promptContent).digest('hex'); + const differentHash = 'different-hash-value'; + jest.mocked(fileSystem.readFileContent) + .mockResolvedValueOnce(promptContent) + .mockResolvedValueOnce(`content_hash: ${differentHash}`); + + const [shouldUpdate] = await shouldUpdateMetadata(promptFile, metadataFile); + expect(shouldUpdate).toBe(true); + }); + + it('should return false when content hash matches', async () => { + appConfig.FORCE_REGENERATE = false; + + const promptContent = 'test content'; + const computedHash = crypto.createHash('md5').update(promptContent).digest('hex'); + const mockMetadataContent = `content_hash: ${computedHash}`; + jest.mocked(fileSystem.fileExists).mockResolvedValue(true); + jest.mocked(fileSystem.readFileContent) + .mockResolvedValueOnce(promptContent) + .mockResolvedValueOnce(mockMetadataContent); + + const [shouldUpdate] = await shouldUpdateMetadata(promptFile, metadataFile); + expect(shouldUpdate).toBe(false); + }); + }); + + describe('updateMetadataHash', () => { + const metadataFile = 'metadata.yml'; + const newHash = 'newhash123'; + it('should update existing hash in metadata file', async () => { + const mockMetadataContent = `content_hash: oldHash123`; + jest.mocked(fileSystem.readFileContent) + .mockResolvedValueOnce(mockPromptContent) + .mockResolvedValueOnce(mockMetadataContent); + await updateMetadataHash(metadataFile, newHash); + + expect(fileSystem.writeFileContent).toHaveBeenCalledWith( + metadataFile, + expect.stringContaining(`content_hash: ${newHash}`) + ); + }); + + it('should add hash if not present in metadata file', async () => { + const mockMetadataContent = ` +title: Test +description: Test description +other: content +`; + jest.mocked(fileSystem.readFileContent).mockResolvedValue(mockMetadataContent); + await updateMetadataHash(metadataFile, newHash); + + expect(fileSystem.writeFileContent).toHaveBeenCalledWith( + metadataFile, + expect.stringContaining(`content_hash: ${newHash}`) + ); + }); + + it('should handle errors during hash update', async () => { + const error = new Error('Update failed'); + jest.mocked(fileSystem.readFileContent).mockRejectedValue(error); + jest.mocked(fileSystem.writeFileContent).mockRejectedValue(error); + + await expect(updateMetadataHash(metadataFile, newHash)).rejects.toThrow(error); + expect(logger.error).toHaveBeenCalled(); + }); + }); + + describe('updatePromptMetadata', () => { + it('should process main prompt file if it exists', async () => { + const mainPromptFile = path.join(appConfig.PROMPTS_DIR, commonConfig.PROMPT_FILE_NAME); + jest.mocked(fileSystem.fileExists).mockResolvedValueOnce(true); + + await updatePromptMetadata(); + + expect(fileSystem.readFileContent).toHaveBeenCalledWith(mainPromptFile); + expect(fileSystem.createDirectory).toHaveBeenCalled(); + expect(fileSystem.renameFile).toHaveBeenCalled(); + expect(fileSystem.writeFileContent).toHaveBeenCalled(); + }); + + it('should process prompt directories', async () => { + jest.mocked(fileSystem.fileExists).mockResolvedValueOnce(false).mockResolvedValueOnce(true); + + await updatePromptMetadata(); + + expect(fileSystem.readDirectory).toHaveBeenCalledWith(appConfig.PROMPTS_DIR); + expect(fileSystem.isDirectory).toHaveBeenCalled(); + expect(fileSystem.readFileContent).toHaveBeenCalled(); + }); + + it('should handle errors during prompt processing', async () => { + const error = new Error('Processing failed'); + jest.mocked(fileSystem.readDirectory).mockRejectedValue(error); + + await expect(updatePromptMetadata()).rejects.toThrow('Processing failed'); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should handle existing target directory during rename', async () => { + const oldDir = 'old-prompt'; + const newDir = 'new-prompt'; + const promptFile = commonConfig.PROMPT_FILE_NAME; + jest.mocked(fileSystem.fileExists).mockImplementation((filePath: string) => { + if (filePath.includes('prompt.md')) { + return Promise.resolve(true); + } else if (filePath.includes('metadata.yml')) { + return Promise.resolve(true); + } else if (filePath.includes(newDir)) { + return Promise.resolve(true); + } else { + return Promise.resolve(false); + } + }); + + jest.mocked(fileSystem.readDirectory).mockImplementation((dirPath: string) => { + if (dirPath === appConfig.PROMPTS_DIR) { + return Promise.resolve([oldDir]); + } else if (dirPath.includes(oldDir)) { + return Promise.resolve([promptFile]); + } else { + return Promise.resolve([]); + } + }); + + jest.mocked(promptAnalyzer.processMetadataGeneration).mockResolvedValue({ + ...mockMetadata, + directory: newDir + }); + + await updatePromptMetadata(); + + expect(fileSystem.copyFile).toHaveBeenCalledWith( + expect.stringContaining(path.join(oldDir, promptFile)), + expect.stringContaining(path.join(newDir, promptFile)) + ); + + expect(fileSystem.removeDirectory).toHaveBeenCalledWith(expect.stringContaining(oldDir)); + }); + + it('should handle existing target directory during rename', async () => { + const oldDir = 'old-prompt'; + const newDir = 'new-prompt'; + const promptFile = commonConfig.PROMPT_FILE_NAME; + jest.mocked(fileSystem.fileExists).mockImplementation((filePath: string) => { + if (filePath.includes('prompt.md')) { + return Promise.resolve(true); + } else if (filePath.includes('metadata.yml')) { + return Promise.resolve(true); + } else if (filePath.includes(newDir)) { + return Promise.resolve(true); + } else { + return Promise.resolve(false); + } + }); + + jest.mocked(fileSystem.readDirectory).mockImplementation((dirPath: string) => { + if (dirPath === appConfig.PROMPTS_DIR) { + return Promise.resolve([oldDir]); + } else if (dirPath.includes(oldDir)) { + return Promise.resolve([promptFile]); + } else { + return Promise.resolve([]); + } + }); + + jest.mocked(promptAnalyzer.processMetadataGeneration).mockResolvedValue({ + ...mockMetadata, + directory: newDir + }); + + jest.mocked(fileSystem.isFile).mockResolvedValue(true); + jest.mocked(fileSystem.isDirectory).mockResolvedValue(true); + + await updatePromptMetadata(); + + expect(fileSystem.copyFile).toHaveBeenCalledWith( + expect.stringContaining(path.join(oldDir, promptFile)), + expect.stringContaining(path.join(newDir, promptFile)) + ); + expect(fileSystem.removeDirectory).toHaveBeenCalledWith(expect.stringContaining(oldDir)); + }); + }); +}); diff --git a/src/app/controllers/__tests__/update-views.test.ts b/src/app/controllers/__tests__/update-views.test.ts new file mode 100644 index 0000000..2ad8e29 --- /dev/null +++ b/src/app/controllers/__tests__/update-views.test.ts @@ -0,0 +1,121 @@ +import * as path from 'path'; + +import { jest } from '@jest/globals'; +import * as nunjucks from 'nunjucks'; + +import { commonConfig } from '../../../shared/config/common-config'; +import { PromptMetadata } from '../../../shared/types'; +import * as fileSystem from '../../../shared/utils/file-system'; +import logger from '../../../shared/utils/logger'; +import { appConfig } from '../../config/app-config'; +import { updateViews } from '../update-views'; + +jest.mock('nunjucks'); +jest.mock('../../../shared/utils/file-system'); +jest.mock('../../../shared/utils/logger'); + +describe('UpdateViewsController', () => { + const mockPromptDir = 'test-prompt'; + const mockPromptPath = path.join(appConfig.PROMPTS_DIR, mockPromptDir); + const mockMetadata: PromptMetadata = { + title: 'Test Prompt', + primary_category: 'Testing', + one_line_description: 'A test prompt', + subcategories: ['unit-test'], + description: '', + directory: '', + tags: [], + variables: [] + }; + const mockPromptContent = 'Test prompt content'; + const mockViewContent = 'Generated view content'; + beforeEach(() => { + jest.clearAllMocks(); + jest.mocked(fileSystem.isDirectory).mockResolvedValue(true); + jest.mocked(fileSystem.readDirectory).mockResolvedValue([mockPromptDir]); + jest.mocked(fileSystem.readFileContent).mockImplementation(async (filePath: string) => { + if (filePath.endsWith(commonConfig.PROMPT_FILE_NAME)) { + return mockPromptContent; + } + + if (filePath.endsWith(commonConfig.METADATA_FILE_NAME)) { + return JSON.stringify(mockMetadata); + } + return ''; + }); + jest.mocked(fileSystem.writeFileContent).mockResolvedValue(); + jest.mocked(nunjucks.render).mockImplementation(() => mockViewContent); + jest.mocked(nunjucks.configure).mockReturnValue(new nunjucks.Environment()); + }); + + it('should process prompt directories and generate views', async () => { + await updateViews(); + expect(nunjucks.configure).toHaveBeenCalledWith(appConfig.TEMPLATES_DIR, { autoescape: false }); + expect(fileSystem.readDirectory).toHaveBeenCalledWith(appConfig.PROMPTS_DIR); + + expect(fileSystem.readFileContent).toHaveBeenCalledWith( + path.join(mockPromptPath, commonConfig.PROMPT_FILE_NAME) + ); + expect(fileSystem.readFileContent).toHaveBeenCalledWith( + path.join(mockPromptPath, commonConfig.METADATA_FILE_NAME) + ); + expect(nunjucks.render).toHaveBeenCalledWith( + appConfig.VIEW_TEMPLATE_NAME, + expect.objectContaining({ + metadata: mockMetadata, + prompt_content: mockPromptContent + }) + ); + expect(fileSystem.writeFileContent).toHaveBeenCalledWith( + path.join(mockPromptPath, appConfig.VIEW_FILE_NAME), + mockViewContent + ); + expect(nunjucks.render).toHaveBeenCalledWith( + appConfig.README_TEMPLATE_NAME, + expect.objectContaining({ + categories: expect.any(Object) + }) + ); + }); + + it('should handle errors gracefully', async () => { + const mockError = new Error('Test error'); + jest.mocked(fileSystem.readDirectory).mockRejectedValue(mockError); + + await expect(updateViews()).rejects.toThrow(mockError); + expect(logger.error).toHaveBeenCalled(); + }); + + it('should skip non-directory entries', async () => { + jest.mocked(fileSystem.isDirectory).mockResolvedValue(false); + + await updateViews(); + + expect(fileSystem.readFileContent).not.toHaveBeenCalled(); + expect(nunjucks.render).toHaveBeenCalledTimes(1); + }); + + it('should use default category when primary_category is missing', async () => { + const metadataWithoutCategory: PromptMetadata = { + ...mockMetadata, + primary_category: appConfig.DEFAULT_CATEGORY + }; + jest.mocked(fileSystem.readFileContent).mockImplementation(async (filePath: string) => { + if (filePath.endsWith(commonConfig.METADATA_FILE_NAME)) { + return JSON.stringify(metadataWithoutCategory); + } + return mockPromptContent; + }); + + await updateViews(); + + expect(nunjucks.render).toHaveBeenCalledWith( + appConfig.README_TEMPLATE_NAME, + expect.objectContaining({ + categories: expect.objectContaining({ + [appConfig.DEFAULT_CATEGORY]: expect.any(Array) + }) + }) + ); + }); +}); diff --git a/src/app/core/update_metadata.ts b/src/app/controllers/update-metadata.ts similarity index 88% rename from src/app/core/update_metadata.ts rename to src/app/controllers/update-metadata.ts index e19dc3b..286b6b1 100644 --- a/src/app/core/update_metadata.ts +++ b/src/app/controllers/update-metadata.ts @@ -1,8 +1,8 @@ import * as crypto from 'crypto'; import * as path from 'path'; -import { commonConfig } from '../../shared/config/common.config'; -import { Metadata } from '../../shared/types'; +import { commonConfig } from '../../shared/config/common-config'; +import { PromptMetadata } from '../../shared/types'; import { copyFile, createDirectory, @@ -14,13 +14,13 @@ import { removeDirectory, renameFile, writeFileContent -} from '../../shared/utils/file_system.util'; -import logger from '../../shared/utils/logger.util'; -import { appConfig } from '../config/app.config'; -import { processMetadataGeneration } from '../utils/prompt_analyzer.util'; -import { dumpYamlContent, sanitizeYamlContent } from '../utils/yaml_operations.util'; +} from '../../shared/utils/file-system'; +import logger from '../../shared/utils/logger'; +import { appConfig } from '../config/app-config'; +import { processMetadataGeneration } from '../utils/metadata-generator'; +import { dumpYamlContent, sanitizeYamlContent } from '../utils/yaml-operations'; -export async function generateMetadata(promptContent: string): Promise { +export async function generateMetadata(promptContent: string): Promise { logger.info('Starting metadata generation'); try { @@ -32,7 +32,7 @@ export async function generateMetadata(promptContent: string): Promise } export async function shouldUpdateMetadata(promptFile: string, metadataFile: string): Promise<[boolean, string]> { - const forceRegenerate = appConfig.FORCE_REGENERATE === 'true'; + const forceRegenerate = appConfig.FORCE_REGENERATE; const promptContent = await readFileContent(promptFile); const promptHash = crypto.createHash('md5').update(promptContent).digest('hex'); @@ -56,8 +56,9 @@ export async function shouldUpdateMetadata(promptFile: string, metadataFile: str } const storedHash = storedHashLine.split(':')[1].trim(); + const hashesMatch = promptHash === storedHash; - if (promptHash !== storedHash) { + if (!hashesMatch) { logger.info(`Content hash mismatch for ${promptFile}. Update needed.`); return [true, promptHash]; } @@ -91,12 +92,12 @@ export async function updateMetadataHash(metadataFile: string, newHash: string): } export async function updatePromptMetadata(): Promise { - logger.info('Starting update_prompt_metadata process'); + logger.info('Starting update-metadata process'); try { await processMainPrompt(appConfig.PROMPTS_DIR); await processPromptDirectories(appConfig.PROMPTS_DIR); - logger.info('update_prompt_metadata process completed'); + logger.info('update-metadata process completed'); } catch (error) { logger.error('Error in updatePromptMetadata:', error); throw error; @@ -201,16 +202,16 @@ async function updatePromptDirectory( if (await fileExists(newDirPath)) { logger.warn(`Directory ${newDirName} already exists. Updating contents.`); const files = await readDirectory(currentItemPath); - await Promise.all( - files.map(async (file) => { - const src = path.join(currentItemPath, file); - const dst = path.join(newDirPath, file); - - if (await isFile(src)) { - await copyFile(src, dst); - } - }) - ); + + for (const file of files) { + const src = path.join(currentItemPath, file); + const dst = path.join(newDirPath, file); + + if (await isFile(src)) { + await copyFile(src, dst); + } + } + await removeDirectory(currentItemPath); } else { await renameFile(currentItemPath, newDirPath); diff --git a/src/app/core/update_views.ts b/src/app/controllers/update-views.ts similarity index 82% rename from src/app/core/update_views.ts rename to src/app/controllers/update-views.ts index c17de1b..0c28465 100644 --- a/src/app/core/update_views.ts +++ b/src/app/controllers/update-views.ts @@ -2,13 +2,13 @@ import * as path from 'path'; import * as nunjucks from 'nunjucks'; -import { commonConfig } from '../../shared/config/common.config'; -import { CategoryItem, Metadata } from '../../shared/types'; -import { isDirectory, readDirectory, readFileContent, writeFileContent } from '../../shared/utils/file_system.util'; -import logger from '../../shared/utils/logger.util'; -import { formatTitleCase } from '../../shared/utils/string_formatter.util'; -import { appConfig } from '../config/app.config'; -import { parseYamlContent } from '../utils/yaml_operations.util'; +import { commonConfig } from '../../shared/config/common-config'; +import { CategoryItem, PromptMetadata } from '../../shared/types'; +import { isDirectory, readDirectory, readFileContent, writeFileContent } from '../../shared/utils/file-system'; +import logger from '../../shared/utils/logger'; +import { formatTitleCase } from '../../shared/utils/string-formatter'; +import { appConfig } from '../config/app-config'; +import { parseYamlContent } from '../utils/yaml-operations'; async function processPromptDirectory(promptDir: string, categories: Record): Promise { const promptPath = path.join(appConfig.PROMPTS_DIR, promptDir); @@ -26,7 +26,7 @@ async function processPromptDirectory(promptDir: string, categories: Record { +async function generateViewFile(promptPath: string, metadata: PromptMetadata, promptContent: string): Promise { try { const viewContent = nunjucks.render(appConfig.VIEW_TEMPLATE_NAME, { metadata, @@ -57,7 +57,7 @@ async function generateViewFile(promptPath: string, metadata: Metadata, promptCo function addPromptToCategories( categories: Record, promptDir: string, - metadata: Metadata + metadata: PromptMetadata ): void { const primaryCategory = metadata.primary_category || appConfig.DEFAULT_CATEGORY; categories[primaryCategory] = categories[primaryCategory] || []; @@ -73,7 +73,7 @@ function addPromptToCategories( } export async function updateViews(): Promise { - logger.info('Starting update_views process'); + logger.info('Starting update-views process'); const categories: Record = {}; try { @@ -84,9 +84,8 @@ export async function updateViews(): Promise { logger.info(`Iterating through prompts in ${appConfig.PROMPTS_DIR}`); const promptDirs = await readDirectory(appConfig.PROMPTS_DIR); await Promise.all(promptDirs.map((promptDir) => processPromptDirectory(promptDir, categories))); - await generateReadme(categories); - logger.info('update_views process completed'); + logger.info('update-views process completed'); } catch (error) { logger.error('Error in updateViews:', error); throw error; @@ -99,6 +98,7 @@ async function generateReadme(categories: Record): Promi Object.entries(categories) .filter(([, v]) => v.length > 0) .sort(([a], [b]) => a.localeCompare(b)) + .map(([category, items]) => [category, items.sort((a, b) => a.title.localeCompare(b.title))]) ); logger.info('Generating README content'); const readmeContent = nunjucks.render(appConfig.README_TEMPLATE_NAME, { diff --git a/src/app/templates/main_readme.md b/src/app/templates/main-readme.md similarity index 100% rename from src/app/templates/main_readme.md rename to src/app/templates/main-readme.md diff --git a/src/app/templates/sub_readme.md b/src/app/templates/sub-readme.md similarity index 100% rename from src/app/templates/sub_readme.md rename to src/app/templates/sub-readme.md diff --git a/src/app/utils/__tests__/analyze-prompt.test.ts b/src/app/utils/__tests__/analyze-prompt.test.ts new file mode 100644 index 0000000..c6b9d3b --- /dev/null +++ b/src/app/utils/__tests__/analyze-prompt.test.ts @@ -0,0 +1,61 @@ +import logger from '../../../shared/utils/logger'; +import { analyzePrompt } from '../analyze-prompt'; +import { processMetadataGeneration } from '../metadata-generator'; + +jest.mock('../metadata-generator', () => ({ + processMetadataGeneration: jest.fn() +})); +jest.mock('../../../shared/utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn() +})); + +describe('AnalyzePromptUtils', () => { + const mockPromptContent = 'Test prompt content'; + const mockMetadata = { + title: 'Test Title', + description: 'Detailed description', + primary_category: 'Test Category', + subcategories: ['sub1', 'sub2'], + directory: 'test-dir', + tags: ['tag1', 'tag2'], + one_line_description: 'Test description', + variables: [ + { + name: 'var1', + type: 'string', + role: 'system', + optional_for_user: false + } + ], + content_hash: 'hash123', + fragments: [ + { + name: 'fragment1', + category: 'test', + variable: 'TEST_VAR' + } + ] + }; + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should analyze prompt successfully', async () => { + (processMetadataGeneration as jest.Mock).mockResolvedValueOnce(mockMetadata); + + const result = await analyzePrompt(mockPromptContent); + expect(result).toEqual(mockMetadata); + expect(logger.info).toHaveBeenCalledWith('Starting prompt analysis'); + expect(logger.info).toHaveBeenCalledWith('Prompt analysis completed successfully'); + }); + + it('should handle errors during analysis', async () => { + const error = new Error('Analysis failed'); + (processMetadataGeneration as jest.Mock).mockRejectedValueOnce(error); + + await expect(analyzePrompt(mockPromptContent)).rejects.toThrow('Analysis failed'); + expect(logger.error).toHaveBeenCalledWith('Error analyzing prompt:', error); + }); +}); diff --git a/src/app/utils/__tests__/fragment-manager.test.ts b/src/app/utils/__tests__/fragment-manager.test.ts new file mode 100644 index 0000000..e117503 --- /dev/null +++ b/src/app/utils/__tests__/fragment-manager.test.ts @@ -0,0 +1,77 @@ +import { readDirectory, isDirectory } from '../../../shared/utils/file-system'; +import logger from '../../../shared/utils/logger'; +import { listAvailableFragments } from '../fragment-manager'; + +jest.mock('../../../shared/utils/file-system'); +jest.mock('../../../shared/utils/logger'); +jest.mock('../../config/app-config', () => ({ + appConfig: { + FRAGMENTS_DIR: '/mock/fragments/dir' + } +})); + +describe('FragmentManagerUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should list fragments from all categories', async () => { + const mockReadDirectory = readDirectory as jest.MockedFunction; + const mockIsDirectory = isDirectory as jest.MockedFunction; + mockReadDirectory.mockImplementationOnce(async () => ['category1', 'category2']); + + mockReadDirectory + .mockImplementationOnce(async () => ['fragment1.js', 'fragment2.js']) + .mockImplementationOnce(async () => ['fragment3.js']); + + mockIsDirectory.mockImplementation(async () => true); + + const result = await listAvailableFragments(); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ + category1: ['fragment1', 'fragment2'], + category2: ['fragment3'] + }); + + expect(logger.info).toHaveBeenCalledWith('Listing available fragments'); + expect(logger.info).toHaveBeenCalledWith('Listed fragments from 2 categories'); + }); + + it('should skip non-directory entries', async () => { + const mockReadDirectory = readDirectory as jest.MockedFunction; + const mockIsDirectory = isDirectory as jest.MockedFunction; + mockReadDirectory.mockImplementationOnce(async () => ['category1', 'not-a-dir']); + mockReadDirectory.mockImplementationOnce(async () => ['fragment1.js']); + + mockIsDirectory.mockImplementationOnce(async () => true).mockImplementationOnce(async () => false); + + const result = await listAvailableFragments(); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ + category1: ['fragment1'] + }); + }); + + it('should handle empty categories', async () => { + const mockReadDirectory = readDirectory as jest.MockedFunction; + const mockIsDirectory = isDirectory as jest.MockedFunction; + mockReadDirectory.mockImplementationOnce(async () => ['empty-category']); + mockReadDirectory.mockImplementationOnce(async () => []); + mockIsDirectory.mockImplementation(async () => true); + + const result = await listAvailableFragments(); + const parsed = JSON.parse(result); + expect(parsed).toEqual({ + 'empty-category': [] + }); + }); + + it('should handle errors and log them', async () => { + const mockReadDirectory = readDirectory as jest.MockedFunction; + const error = new Error('Test error'); + mockReadDirectory.mockRejectedValue(error); + + await expect(listAvailableFragments()).rejects.toThrow('Test error'); + expect(logger.error).toHaveBeenCalledWith('Error listing available fragments:', error); + }); +}); diff --git a/src/app/utils/__tests__/metadata-generator.test.ts b/src/app/utils/__tests__/metadata-generator.test.ts new file mode 100644 index 0000000..f997ec0 --- /dev/null +++ b/src/app/utils/__tests__/metadata-generator.test.ts @@ -0,0 +1,114 @@ +import { readFileContent } from '../../../shared/utils/file-system'; +import logger from '../../../shared/utils/logger'; +import { processPromptContent } from '../../../shared/utils/prompt-processing'; +import { appConfig } from '../../config/app-config'; +import { listAvailableFragments } from '../fragment-manager'; +import { loadAnalyzerPrompt, processMetadataGeneration } from '../metadata-generator'; +import { parseYamlContent } from '../yaml-operations'; + +jest.mock('../fragment-manager'); +jest.mock('../yaml-operations'); +jest.mock('../../../shared/utils/file-system'); +jest.mock('../../../shared/utils/prompt-processing', () => ({ + processPromptContent: jest.fn(), + updatePromptWithVariables: jest.fn() +})); +jest.mock('../../config/app-config', () => ({ + appConfig: { + ANALYZER_PROMPT_PATH: '/mock/analyzer/prompt.txt' + } +})); +jest.mock('../analyze-prompt', () => ({ + analyzePrompt: jest.fn() +})); +jest.mock('../../../shared/utils/logger', () => ({ + info: jest.fn(), + error: jest.fn(), + warn: jest.fn() +})); + +describe('MetadataGeneratorUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('loadAnalyzerPrompt', () => { + it('should load analyzer prompt successfully', async () => { + const mockContent = 'Mock analyzer prompt content'; + (readFileContent as jest.Mock).mockResolvedValue(mockContent); + + const result = await loadAnalyzerPrompt(); + expect(result).toBe(mockContent); + expect(readFileContent).toHaveBeenCalledWith(appConfig.ANALYZER_PROMPT_PATH); + expect(logger.info).toHaveBeenCalledWith(expect.stringContaining('Loading analyzer prompt')); + }); + + it('should handle errors when loading analyzer prompt', async () => { + const error = new Error('Failed to read file'); + (readFileContent as jest.Mock).mockRejectedValue(error); + + await expect(loadAnalyzerPrompt()).rejects.toThrow('Failed to read file'); + expect(logger.error).toHaveBeenCalledWith('Error loading analyzer prompt:', error); + }); + }); + + describe('processMetadataGeneration', () => { + const mockPromptContent = 'Test prompt content'; + const mockAnalyzerPrompt = 'Analyzer prompt'; + const mockFragments = '{"category": ["fragment1"]}'; + const mockProcessedContent = 'yaml: content'; + const mockParsedMetadata = { + title: 'Test Title', + primary_category: 'Test Category', + subcategories: ['sub1', 'sub2'], + directory: 'test-dir', + tags: ['tag1', 'tag2'], + one_line_description: 'Test description', + description: 'Detailed description', + variables: [ + { + name: 'var1', + type: 'string', + role: 'system', + optional_for_user: false + } + ], + content_hash: 'hash123', + fragments: [ + { + name: 'fragment1', + category: 'test', + variable: 'TEST_VAR' + } + ] + }; + beforeEach(() => { + (readFileContent as jest.Mock).mockResolvedValue(mockAnalyzerPrompt); + (listAvailableFragments as jest.Mock).mockResolvedValue(mockFragments); + (processPromptContent as jest.Mock).mockResolvedValue(mockProcessedContent); + (parseYamlContent as jest.Mock).mockReturnValue(mockParsedMetadata); + }); + + it('should generate metadata successfully', async () => { + const result = await processMetadataGeneration(mockPromptContent); + expect(result).toEqual(mockParsedMetadata); + expect(listAvailableFragments).toHaveBeenCalled(); + expect(processPromptContent).toHaveBeenCalled(); + expect(parseYamlContent).toHaveBeenCalled(); + }); + + it('should throw error for invalid metadata', async () => { + const invalidMetadata = { ...mockParsedMetadata, title: '' }; + (parseYamlContent as jest.Mock).mockReturnValue(invalidMetadata); + + await expect(processMetadataGeneration(mockPromptContent)).rejects.toThrow('Invalid metadata generated'); + }); + + it('should handle missing output tags', async () => { + (processPromptContent as jest.Mock).mockResolvedValue('content without tags'); + await processMetadataGeneration(mockPromptContent); + + expect(logger.warn).toHaveBeenCalledWith('Output tags not found in content, returning trimmed content'); + }); + }); +}); diff --git a/src/app/utils/__tests__/prompt-analyzer-cli.test.ts b/src/app/utils/__tests__/prompt-analyzer-cli.test.ts new file mode 100644 index 0000000..478bd21 --- /dev/null +++ b/src/app/utils/__tests__/prompt-analyzer-cli.test.ts @@ -0,0 +1,83 @@ +import { readFileContent } from '../../../shared/utils/file-system'; +import { analyzePrompt } from '../analyze-prompt'; +import { runPromptAnalyzerFromCLI } from '../prompt-analyzer-cli'; + +jest.mock('../../../shared/utils/file-system'); +jest.mock('../analyze-prompt', () => ({ + analyzePrompt: jest.fn() +})); + +describe('PromptAnalyzerCLIUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('runPromptAnalyzerFromCLI', () => { + let mockExit: jest.SpyInstance; + beforeEach(() => { + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(console, 'log').mockImplementation(() => {}); + mockExit = jest + .spyOn(process, 'exit') + .mockImplementation((code?: number | string | null | undefined): never => undefined as never); + }); + + afterEach(() => { + (console.error as jest.Mock).mockRestore(); + (console.log as jest.Mock).mockRestore(); + mockExit.mockRestore(); + }); + + it('should read prompt file and analyze prompt', async () => { + const mockPromptPath = '/path/to/prompt.txt'; + const mockPromptContent = 'Test prompt content'; + const mockMetadata = { + title: 'Test Title', + description: 'Detailed description', + primary_category: 'Test Category', + subcategories: ['sub1', 'sub2'], + directory: 'test-dir', + tags: ['tag1', 'tag2'], + one_line_description: 'Test description', + variables: [ + { + name: 'var1', + type: 'string', + role: 'system', + optional_for_user: false + } + ], + content_hash: 'hash123', + fragments: [ + { + name: 'fragment1', + category: 'test', + variable: 'TEST_VAR' + } + ] + }; + (readFileContent as jest.Mock).mockResolvedValueOnce(mockPromptContent); + (analyzePrompt as jest.Mock).mockResolvedValueOnce(mockMetadata); + + await runPromptAnalyzerFromCLI([mockPromptPath]); + + expect(readFileContent).toHaveBeenCalledWith(mockPromptPath); + expect(analyzePrompt).toHaveBeenCalledWith(mockPromptContent); + expect(console.log).toHaveBeenCalledWith('Generated Metadata:'); + expect(console.log).toHaveBeenCalledWith(JSON.stringify(mockMetadata, null, 2)); + + expect(mockExit).toHaveBeenCalledWith(0); + }); + + it('should handle errors during processing', async () => { + const mockPromptPath = '/path/to/prompt.txt'; + const error = new Error('Test error'); + (readFileContent as jest.Mock).mockRejectedValue(error); + + await runPromptAnalyzerFromCLI([mockPromptPath]); + + expect(console.error).toHaveBeenCalledWith('Error:', error); + expect(mockExit).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/src/app/utils/__tests__/yaml-operations.test.ts b/src/app/utils/__tests__/yaml-operations.test.ts new file mode 100644 index 0000000..27bb2a4 --- /dev/null +++ b/src/app/utils/__tests__/yaml-operations.test.ts @@ -0,0 +1,203 @@ +import * as yaml from 'js-yaml'; + +import { PromptMetadata } from '../../../shared/types'; +import logger from '../../../shared/utils/logger'; +import { appConfig } from '../../config/app-config'; +import { + parseYamlContent, + dumpYamlContent, + isValidMetadata, + parseAndValidateYamlContent, + sanitizeYamlContent, + needsSanitization +} from '../yaml-operations'; + +jest.mock('../../../shared/utils/logger', () => ({ + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn() +})); +jest.mock('js-yaml', () => { + const originalModule = jest.requireActual('js-yaml'); + return { + ...originalModule, + dump: jest.fn(originalModule.dump) + }; +}); + +describe('YAMLOperationsUtils', () => { + const mockValidMetadata: PromptMetadata = { + title: 'Test Title', + primary_category: 'Test Category', + subcategories: ['sub1', 'sub2'], + directory: 'test-dir', + tags: ['tag1', 'tag2'], + one_line_description: 'Test description', + description: 'Detailed description', + variables: [ + { + name: 'var1', + role: 'system', + optional_for_user: false + } + ] + }; + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('parseYamlContent', () => { + it('should parse valid YAML content', () => { + const yamlString = yaml.dump(mockValidMetadata); + const result = parseYamlContent(yamlString); + expect(result).toEqual(mockValidMetadata); + }); + + it('should handle XML-like wrapped content', () => { + const wrappedContent = `\n${yaml.dump(mockValidMetadata)}`; + const result = parseYamlContent(wrappedContent); + expect(result).toEqual(mockValidMetadata); + }); + + it('should throw error for invalid YAML', () => { + const invalidYaml = '{\n invalid: yaml: content:'; + expect(() => parseYamlContent(invalidYaml)).toThrow(); + }); + }); + + describe('dumpYamlContent', () => { + it('should dump metadata to YAML format', () => { + const result = dumpYamlContent(mockValidMetadata); + const parsed = yaml.load(result) as PromptMetadata; + expect(parsed).toEqual(mockValidMetadata); + }); + + it('should use configured indent and line width', () => { + const result = dumpYamlContent(mockValidMetadata); + const lines = result.split('\n'); + const indentMatch = lines.some((line) => line.startsWith(' '.repeat(appConfig.YAML_INDENT))); + expect(indentMatch).toBeTruthy(); + }); + + it('should throw error for invalid input', () => { + const invalidInput = { + circular: {} + }; + invalidInput.circular = invalidInput; + expect(() => dumpYamlContent(invalidInput as any)).toThrow(); + }); + }); + + describe('isValidMetadata', () => { + it('should validate correct metadata', () => { + expect(isValidMetadata(mockValidMetadata)).toBeTruthy(); + }); + + it('should reject null input', () => { + expect(isValidMetadata(null)).toBeFalsy(); + }); + + it('should reject missing required fields', () => { + const invalidMetadata = { ...mockValidMetadata }; + delete (invalidMetadata as any).title; + expect(isValidMetadata(invalidMetadata)).toBeFalsy(); + }); + + it('should reject invalid variable structure', () => { + const invalidMetadata = { + ...mockValidMetadata, + variables: [{ invalid: 'structure' }] + }; + expect(isValidMetadata(invalidMetadata)).toBeFalsy(); + }); + + it('should reject variables that are not objects', () => { + const invalidMetadata = { + ...mockValidMetadata, + variables: [null] + }; + expect(isValidMetadata(invalidMetadata)).toBeFalsy(); + }); + }); + + describe('parseAndValidateYamlContent', () => { + it('should parse and validate correct YAML', () => { + const yamlString = yaml.dump(mockValidMetadata); + const result = parseAndValidateYamlContent(yamlString); + expect(result).toEqual(mockValidMetadata); + }); + + it('should throw error for invalid metadata structure', () => { + const invalidYaml = yaml.dump({ invalid: 'structure' }); + expect(() => parseAndValidateYamlContent(invalidYaml)).toThrow(); + }); + }); + + describe('sanitizeYamlContent', () => { + it('should handle simple key-value content', () => { + const content = 'content_hash: abc123\n'; + const result = sanitizeYamlContent(content); + expect(result).toBe(content); + }); + + it('should sanitize complex YAML content', () => { + const messyYaml = yaml.dump(mockValidMetadata).replace(/\n/g, '\n\n'); + const result = sanitizeYamlContent(messyYaml); + expect(result).toBe( + yaml + .dump(mockValidMetadata, { + indent: appConfig.YAML_INDENT, + lineWidth: appConfig.YAML_LINE_WIDTH, + noRefs: true, + sortKeys: true + }) + .trim() + '\n' + ); + }); + + it('should handle JSON content', () => { + const jsonContent = JSON.stringify(mockValidMetadata); + const result = sanitizeYamlContent(jsonContent); + expect(() => yaml.load(result)).not.toThrow(); + }); + + it('should return content as-is if both JSON and YAML parsing fail', () => { + const invalidContent = 'not valid yaml or json'; + const result = sanitizeYamlContent(invalidContent); + expect(result).toBe(invalidContent.trim() + '\n'); + }); + + it('should throw error when dumping fails in sanitizeYamlContent', () => { + const content = 'valid: yaml'; + (yaml.dump as jest.Mock).mockImplementationOnce(() => { + throw new Error('Dumping failed'); + }); + + expect(() => sanitizeYamlContent(content)).toThrow('Dumping failed'); + }); + }); + + describe('needsSanitization', () => { + it('should detect content needing sanitization', () => { + const messyYaml = yaml.dump(mockValidMetadata).replace(/\n/g, '\n\n'); + expect(needsSanitization(messyYaml)).toBeTruthy(); + }); + + it('should pass already sanitized content', () => { + const cleanYaml = yaml.dump(mockValidMetadata, { + indent: appConfig.YAML_INDENT, + lineWidth: appConfig.YAML_LINE_WIDTH, + noRefs: true, + sortKeys: true + }); + expect(needsSanitization(cleanYaml)).toBeFalsy(); + }); + + it('should return true for invalid YAML content needing sanitization', () => { + const invalidYaml = 'invalid: yaml: content'; + const result = needsSanitization(invalidYaml); + expect(result).toBeTruthy(); + expect(logger.error).toHaveBeenCalledWith('Error checking YAML sanitization:', expect.any(Error)); + }); + }); +}); diff --git a/src/app/utils/analyze-prompt.ts b/src/app/utils/analyze-prompt.ts new file mode 100644 index 0000000..2f23d43 --- /dev/null +++ b/src/app/utils/analyze-prompt.ts @@ -0,0 +1,15 @@ +import { processMetadataGeneration } from './metadata-generator'; +import { PromptMetadata } from '../../shared/types'; +import logger from '../../shared/utils/logger'; + +export async function analyzePrompt(promptContent: string): Promise { + try { + logger.info('Starting prompt analysis'); + const metadata = await processMetadataGeneration(promptContent); + logger.info('Prompt analysis completed successfully'); + return metadata; + } catch (error) { + logger.error('Error analyzing prompt:', error); + throw error; + } +} diff --git a/src/app/utils/fragment_manager.util.ts b/src/app/utils/fragment-manager.ts similarity index 90% rename from src/app/utils/fragment_manager.util.ts rename to src/app/utils/fragment-manager.ts index fbda61a..58dd2ba 100644 --- a/src/app/utils/fragment_manager.util.ts +++ b/src/app/utils/fragment-manager.ts @@ -1,8 +1,8 @@ import path from 'path'; -import { isDirectory, readDirectory } from '../../shared/utils/file_system.util'; -import logger from '../../shared/utils/logger.util'; -import { appConfig } from '../config/app.config'; +import { isDirectory, readDirectory } from '../../shared/utils/file-system'; +import logger from '../../shared/utils/logger'; +import { appConfig } from '../config/app-config'; export async function listAvailableFragments(): Promise { try { diff --git a/src/app/utils/prompt_analyzer.util.ts b/src/app/utils/metadata-generator.ts similarity index 66% rename from src/app/utils/prompt_analyzer.util.ts rename to src/app/utils/metadata-generator.ts index 4fda4e5..aa98a76 100644 --- a/src/app/utils/prompt_analyzer.util.ts +++ b/src/app/utils/metadata-generator.ts @@ -1,10 +1,10 @@ -import { listAvailableFragments } from './fragment_manager.util'; -import { parseYamlContent } from './yaml_operations.util'; -import { Metadata } from '../../shared/types'; -import { readFileContent } from '../../shared/utils/file_system.util'; -import logger from '../../shared/utils/logger.util'; -import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt_processing.util'; -import { appConfig } from '../config/app.config'; +import { listAvailableFragments } from './fragment-manager'; +import { parseYamlContent } from './yaml-operations'; +import { PromptMetadata } from '../../shared/types'; +import { readFileContent } from '../../shared/utils/file-system'; +import logger from '../../shared/utils/logger'; +import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt-processing'; +import { appConfig } from '../config/app-config'; export async function loadAnalyzerPrompt(): Promise { try { @@ -18,7 +18,7 @@ export async function loadAnalyzerPrompt(): Promise { } } -export async function processMetadataGeneration(promptContent: string): Promise { +export async function processMetadataGeneration(promptContent: string): Promise { logger.info('Processing prompt for metadata generation'); try { @@ -34,7 +34,7 @@ export async function processMetadataGeneration(promptContent: string): Promise< const content = await processPromptContent([{ role: 'user', content: updatedPromptContent }], false); const yamlContent = extractOutputContent(content); const parsedMetadata = parseYamlContent(yamlContent); - const metadata: Metadata = { + const metadata: PromptMetadata = { title: parsedMetadata.title || '', primary_category: parsedMetadata.primary_category || '', subcategories: parsedMetadata.subcategories || [], @@ -68,7 +68,7 @@ function extractOutputContent(content: string): string { return content.slice(outputStart + 8, outputEnd).trim(); } -function isValidMetadata(metadata: Metadata): boolean { +function isValidMetadata(metadata: PromptMetadata): boolean { if (!metadata.title || !metadata.description || !metadata.primary_category) { logger.warn('Missing one or more required fields in metadata: title, description, or primary_category'); return false; @@ -80,36 +80,3 @@ function isValidMetadata(metadata: Metadata): boolean { } return true; } - -export async function analyzePrompt(promptContent: string): Promise { - try { - logger.info('Starting prompt analysis'); - const metadata = await processMetadataGeneration(promptContent); - logger.info('Prompt analysis completed successfully'); - return metadata; - } catch (error) { - logger.error('Error analyzing prompt:', error); - throw error; - } -} - -if (require.main === module) { - // This block will be executed if the script is run directly - const promptPath = process.argv[2]; - - if (!promptPath) { - console.error('Please provide a path to the prompt file as an argument'); - process.exit(1); - } - - readFileContent(promptPath) - .then(analyzePrompt) - .then((metadata) => { - console.log('Generated Metadata:'); - console.log(JSON.stringify(metadata, null, 2)); - }) - .catch((error) => { - console.error('Error:', error); - process.exit(1); - }); -} diff --git a/src/app/utils/prompt-analyzer-cli.ts b/src/app/utils/prompt-analyzer-cli.ts new file mode 100644 index 0000000..e4c361e --- /dev/null +++ b/src/app/utils/prompt-analyzer-cli.ts @@ -0,0 +1,26 @@ +import { analyzePrompt } from './analyze-prompt'; +import { readFileContent } from '../../shared/utils/file-system'; + +export async function runPromptAnalyzerFromCLI(args: string[]) { + const promptPath = args[0]; + + if (!promptPath) { + console.error('Please provide a path to the prompt file as an argument'); + process.exit(1); + } + + try { + const promptContent = await readFileContent(promptPath); + const metadata = await analyzePrompt(promptContent); + console.log('Generated Metadata:'); + console.log(JSON.stringify(metadata, null, 2)); + process.exit(0); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } +} + +if (require.main === module) { + runPromptAnalyzerFromCLI(process.argv.slice(2)); +} diff --git a/src/app/utils/yaml_operations.util.ts b/src/app/utils/yaml-operations.ts similarity index 63% rename from src/app/utils/yaml_operations.util.ts rename to src/app/utils/yaml-operations.ts index 32449b2..82b59b8 100644 --- a/src/app/utils/yaml_operations.util.ts +++ b/src/app/utils/yaml-operations.ts @@ -1,21 +1,15 @@ import * as yaml from 'js-yaml'; -import { Metadata, Variable } from '../../shared/types'; -import logger from '../../shared/utils/logger.util'; -import { appConfig } from '../config/app.config'; - -/** - * Parses YAML content into a Metadata object. - * @param {string} yamlContent - The YAML content to parse. - * @returns {Metadata} The parsed Metadata object. - * @throws {Error} If parsing fails. - */ -export function parseYamlContent(yamlContent: string): Metadata { +import { PromptMetadata, Variable } from '../../shared/types'; +import logger from '../../shared/utils/logger'; +import { appConfig } from '../config/app-config'; + +export function parseYamlContent(yamlContent: string): PromptMetadata { try { logger.debug('Preparing content for YAML parsing'); yamlContent = yamlContent.replace(/^\s*<[^>]+>\s*([\s\S]*?)\s*<\/[^>]+>\s*$/, '$1'); logger.debug('Parsing YAML content'); - const parsedContent = yaml.load(yamlContent) as Metadata; + const parsedContent = yaml.load(yamlContent) as PromptMetadata; logger.debug('YAML content parsed successfully'); return parsedContent; } catch (error) { @@ -24,13 +18,7 @@ export function parseYamlContent(yamlContent: string): Metadata { } } -/** - * Dumps a Metadata object into a YAML string. - * @param {Metadata} data - The Metadata object to dump. - * @returns {string} The YAML string representation of the Metadata. - * @throws {Error} If dumping fails. - */ -export function dumpYamlContent(data: Metadata): string { +export function dumpYamlContent(data: PromptMetadata): string { try { logger.debug('Dumping Metadata to YAML'); const yamlString = yaml.dump(data, { @@ -46,20 +34,15 @@ export function dumpYamlContent(data: Metadata): string { } } -/** - * Validates that a parsed YAML object conforms to the Metadata interface. - * @param {unknown} obj - The object to validate. - * @returns {obj is Metadata} True if the object is valid Metadata, false otherwise. - */ -export function isValidMetadata(obj: unknown): obj is Metadata { - const metadata = obj as Partial; +export function isValidMetadata(obj: unknown): obj is PromptMetadata { + const metadata = obj as Partial; if (typeof metadata !== 'object' || metadata === null) { logger.error('Invalid Metadata: not an object'); return false; } - const requiredStringFields: (keyof Metadata)[] = [ + const requiredStringFields: (keyof PromptMetadata)[] = [ 'title', 'primary_category', 'directory', @@ -74,7 +57,7 @@ export function isValidMetadata(obj: unknown): obj is Metadata { } } - const requiredArrayFields: (keyof Metadata)[] = ['subcategories', 'tags', 'variables']; + const requiredArrayFields: (keyof PromptMetadata)[] = ['subcategories', 'tags', 'variables']; for (const field of requiredArrayFields) { if (!Array.isArray(metadata[field])) { @@ -90,11 +73,6 @@ export function isValidMetadata(obj: unknown): obj is Metadata { return true; } -/** - * Validates that an object conforms to the Variable interface. - * @param {unknown} obj - The object to validate. - * @returns {obj is Variable} True if the object is a valid Variable, false otherwise. - */ function isValidVariable(obj: unknown): obj is Variable { const variable = obj as Partial; return ( @@ -105,13 +83,7 @@ function isValidVariable(obj: unknown): obj is Variable { ); } -/** - * Parses YAML content and validates it as Metadata. - * @param {string} yamlContent - The YAML content to parse and validate. - * @returns {Metadata} The validated Metadata object. - * @throws {Error} If parsing fails or the content is not valid Metadata. - */ -export function parseAndValidateYamlContent(yamlContent: string): Metadata { +export function parseAndValidateYamlContent(yamlContent: string): PromptMetadata { const parsedContent = parseYamlContent(yamlContent); if (!isValidMetadata(parsedContent)) { @@ -121,15 +93,26 @@ export function parseAndValidateYamlContent(yamlContent: string): Metadata { return parsedContent; } -/** - * Sanitizes YAML content by removing extra spaces and ensuring proper formatting. - * @param {string} content - The YAML content to sanitize. - * @returns {string} Sanitized YAML content. - */ export function sanitizeYamlContent(content: string): string { try { logger.debug('Sanitizing YAML content'); - const parsedContent = yaml.load(content); + + if (content.includes('content_hash:')) { + return content.trim() + '\n'; + } + + let parsedContent; + + try { + parsedContent = JSON.parse(content); + } catch { + try { + parsedContent = yaml.load(content); + } catch { + return content.trim() + '\n'; + } + } + const sanitizedContent = yaml.dump(parsedContent, { indent: appConfig.YAML_INDENT, lineWidth: appConfig.YAML_LINE_WIDTH, @@ -144,11 +127,6 @@ export function sanitizeYamlContent(content: string): string { } } -/** - * Determines if a YAML content needs sanitization. - * @param {string} content - The YAML content to check. - * @returns {boolean} True if the content needs sanitization, false otherwise. - */ export function needsSanitization(content: string): boolean { try { const originalParsed = yaml.load(content); @@ -157,7 +135,7 @@ export function needsSanitization(content: string): boolean { return JSON.stringify(originalParsed) !== JSON.stringify(sanitizedParsed); } catch (error) { logger.error('Error checking YAML sanitization:', error); - return true; // If we can't parse it, it probably needs sanitization + return true; } } diff --git a/src/cli/commands/base.command.ts b/src/cli/commands/base-command.ts similarity index 95% rename from src/cli/commands/base.command.ts rename to src/cli/commands/base-command.ts index cf41e3b..aacbad8 100644 --- a/src/cli/commands/base.command.ts +++ b/src/cli/commands/base-command.ts @@ -7,10 +7,10 @@ import { Command } from 'commander'; import fs from 'fs-extra'; import { ApiResult } from '../../shared/types'; -import { cliConfig } from '../cli.config'; -import { ENV_PREFIX, FRAGMENT_PREFIX } from '../cli.constants'; -import { handleApiResult } from '../utils/database.util'; -import { handleError } from '../utils/error.util'; +import { cliConfig } from '../config/cli-config'; +import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants'; +import { handleApiResult } from '../utils/database'; +import { handleError } from '../utils/errors'; export class BaseCommand extends Command { constructor(name: string, description: string) { diff --git a/src/cli/commands/config.command.ts b/src/cli/commands/config-command.ts similarity index 98% rename from src/cli/commands/config.command.ts rename to src/cli/commands/config-command.ts index b15ccbb..174f6f9 100644 --- a/src/cli/commands/config.command.ts +++ b/src/cli/commands/config-command.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; -import { BaseCommand } from './base.command'; +import { BaseCommand } from './base-command'; import { Config, getConfig, setConfig } from '../../shared/config'; class ConfigCommand extends BaseCommand { diff --git a/src/cli/commands/env.command.ts b/src/cli/commands/env-command.ts similarity index 96% rename from src/cli/commands/env.command.ts rename to src/cli/commands/env-command.ts index 104ec0f..961c1a4 100644 --- a/src/cli/commands/env.command.ts +++ b/src/cli/commands/env-command.ts @@ -1,12 +1,12 @@ import chalk from 'chalk'; -import { BaseCommand } from './base.command'; +import { BaseCommand } from './base-command'; import { EnvVar, Fragment } from '../../shared/types'; -import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string_formatter.util'; -import { FRAGMENT_PREFIX } from '../cli.constants'; -import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../utils/env.util'; -import { listFragments, viewFragmentContent } from '../utils/fragment_operations.util'; -import { listPrompts, getPromptFiles } from '../utils/prompt_crud.util'; +import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string-formatter'; +import { FRAGMENT_PREFIX } from '../constants'; +import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../utils/env-vars'; +import { listFragments, viewFragmentContent } from '../utils/fragments'; +import { listPrompts, getPromptFiles } from '../utils/prompts'; class EnvCommand extends BaseCommand { constructor() { @@ -209,6 +209,10 @@ class EnvCommand extends BaseCommand { const uniqueVariables = new Map(); for (const prompt of prompts) { + if (!prompt.id) { + return []; + } + const details = await this.handleApiResult( await getPromptFiles(prompt.id), `Fetched details for prompt ${prompt.id}` diff --git a/src/cli/commands/execute.command.ts b/src/cli/commands/execute-command.ts similarity index 87% rename from src/cli/commands/execute.command.ts rename to src/cli/commands/execute-command.ts index a7fc44c..2679658 100644 --- a/src/cli/commands/execute.command.ts +++ b/src/cli/commands/execute-command.ts @@ -2,11 +2,10 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import yaml from 'js-yaml'; -import { BaseCommand } from './base.command'; -import { Metadata, Prompt, Variable } from '../../shared/types'; -import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt_processing.util'; -import { getPromptFiles } from '../utils/prompt_crud.util'; -import { viewPromptDetails } from '../utils/prompt_display.util'; +import { BaseCommand } from './base-command'; +import { PromptMetadata, Variable } from '../../shared/types'; +import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt-processing'; +import { getPromptFiles, viewPromptDetails } from '../utils/prompts'; class ExecuteCommand extends BaseCommand { constructor() { @@ -156,7 +155,7 @@ Note: try { const promptContent = await fs.readFile(promptFile, 'utf-8'); const metadataContent = await fs.readFile(metadataFile, 'utf-8'); - const metadata = yaml.load(metadataContent) as Metadata; + const metadata = yaml.load(metadataContent) as PromptMetadata; if (inspect) { await this.inspectPrompt(metadata); @@ -168,7 +167,7 @@ Note: } } - private async inspectPrompt(metadata: Metadata): Promise { + private async inspectPrompt(metadata: PromptMetadata): Promise { try { await viewPromptDetails( { @@ -178,7 +177,7 @@ Note: description: metadata.description, tags: metadata.tags, variables: metadata.variables - } as Prompt & { variables: Variable[] }, + } as PromptMetadata & { variables: Variable[] }, true ); } catch (error) { @@ -188,13 +187,26 @@ Note: private async executePromptWithMetadata( promptContent: string, - metadata: Metadata, + metadata: PromptMetadata, dynamicOptions: Record, fileInputs: Record ): Promise { try { const userInputs: Record = {}; + for (const variable of metadata.variables) { + if (!variable.optional_for_user && !variable.value) { + const snakeCaseName = variable.name.replace(/[{}]/g, '').toLowerCase(); + const hasValue = + (dynamicOptions && snakeCaseName in dynamicOptions) || + (fileInputs && snakeCaseName in fileInputs); + + if (!hasValue) { + throw new Error(`Required variable ${snakeCaseName} is not set`); + } + } + } + for (const variable of metadata.variables) { const snakeCaseName = variable.name.replace(/[{}]/g, '').toLowerCase(); let value = dynamicOptions[snakeCaseName]; @@ -205,13 +217,12 @@ Note: console.log(chalk.green(`Loaded file content for ${snakeCaseName}`)); } catch (error) { console.error(chalk.red(`Error reading file for ${snakeCaseName}:`, error)); + throw new Error(`Failed to read file for ${snakeCaseName}`); } } if (value) { userInputs[variable.name] = value; - } else if (!variable.optional_for_user) { - throw new Error(`Required variable ${snakeCaseName} is not set.`); } } diff --git a/src/cli/commands/flush.command.ts b/src/cli/commands/flush-command.ts similarity index 92% rename from src/cli/commands/flush.command.ts rename to src/cli/commands/flush-command.ts index 0fa79e0..38e67d6 100644 --- a/src/cli/commands/flush.command.ts +++ b/src/cli/commands/flush-command.ts @@ -1,7 +1,7 @@ import chalk from 'chalk'; -import { BaseCommand } from './base.command'; -import { flushData } from '../utils/database.util'; +import { BaseCommand } from './base-command'; +import { flushData } from '../utils/database'; class FlushCommand extends BaseCommand { constructor() { diff --git a/src/cli/commands/fragments.command.ts b/src/cli/commands/fragments-command.ts similarity index 96% rename from src/cli/commands/fragments.command.ts rename to src/cli/commands/fragments-command.ts index a47869c..0f36130 100644 --- a/src/cli/commands/fragments.command.ts +++ b/src/cli/commands/fragments-command.ts @@ -1,9 +1,9 @@ import chalk from 'chalk'; -import { BaseCommand } from './base.command'; +import { BaseCommand } from './base-command'; import { Fragment } from '../../shared/types'; -import { formatTitleCase } from '../../shared/utils/string_formatter.util'; -import { listFragments, viewFragmentContent } from '../utils/fragment_operations.util'; +import { formatTitleCase } from '../../shared/utils/string-formatter'; +import { listFragments, viewFragmentContent } from '../utils/fragments'; type FragmentMenuAction = 'all' | 'category' | 'back'; diff --git a/src/cli/commands/menu.command.ts b/src/cli/commands/menu-command.ts similarity index 93% rename from src/cli/commands/menu.command.ts rename to src/cli/commands/menu-command.ts index b938d1d..e6ebc1e 100644 --- a/src/cli/commands/menu.command.ts +++ b/src/cli/commands/menu-command.ts @@ -1,10 +1,10 @@ import chalk from 'chalk'; import { Command } from 'commander'; -import { BaseCommand } from './base.command'; +import { BaseCommand } from './base-command'; import { getConfig } from '../../shared/config'; -import { handleError } from '../utils/error.util'; -import { hasFragments, hasPrompts } from '../utils/file_system.util'; +import { handleError } from '../utils/errors'; +import { hasFragments, hasPrompts } from '../utils/file-system'; type MenuAction = 'sync' | 'prompts' | 'fragments' | 'settings' | 'env' | 'back'; diff --git a/src/cli/commands/prompts.command.ts b/src/cli/commands/prompts-command.ts similarity index 96% rename from src/cli/commands/prompts.command.ts rename to src/cli/commands/prompts-command.ts index 83119c5..d4ee6f6 100644 --- a/src/cli/commands/prompts.command.ts +++ b/src/cli/commands/prompts-command.ts @@ -1,14 +1,14 @@ import chalk from 'chalk'; -import { BaseCommand } from './base.command'; -import { CategoryItem, EnvVar, Fragment, Prompt, Variable } from '../../shared/types'; -import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string_formatter.util'; -import { ENV_PREFIX, FRAGMENT_PREFIX } from '../cli.constants'; -import { ConversationManager } from '../utils/conversation_manager.util'; -import { fetchCategories, getPromptDetails, updatePromptVariable } from '../utils/database.util'; -import { readEnvVars } from '../utils/env.util'; -import { listFragments, viewFragmentContent } from '../utils/fragment_operations.util'; -import { viewPromptDetails } from '../utils/prompt_display.util'; +import { BaseCommand } from './base-command'; +import { CategoryItem, EnvVar, Fragment, PromptMetadata, Variable } from '../../shared/types'; +import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string-formatter'; +import { ENV_PREFIX, FRAGMENT_PREFIX } from '../constants'; +import { ConversationManager } from '../utils/conversation-manager'; +import { fetchCategories, getPromptDetails, updatePromptVariable } from '../utils/database'; +import { readEnvVars } from '../utils/env-vars'; +import { listFragments, viewFragmentContent } from '../utils/fragments'; +import { viewPromptDetails } from '../utils/prompts'; type PromptMenuAction = 'all' | 'category' | 'id' | 'back'; type SelectPromptMenuAction = Variable | 'execute' | 'unset_all' | 'back'; @@ -183,7 +183,7 @@ class PromptCommand extends BaseCommand { } } - private async selectPromptAction(details: Prompt & { variables: Variable[] }): Promise { + private async selectPromptAction(details: PromptMetadata): Promise { const choices: Array<{ name: string; value: SelectPromptMenuAction }> = []; const allRequiredSet = details.variables.every((v) => v.optional_for_user || v.value); diff --git a/src/cli/commands/settings.command.ts b/src/cli/commands/settings-command.ts similarity index 89% rename from src/cli/commands/settings.command.ts rename to src/cli/commands/settings-command.ts index 3a8cf18..6e04b45 100644 --- a/src/cli/commands/settings.command.ts +++ b/src/cli/commands/settings-command.ts @@ -1,7 +1,7 @@ -import { BaseCommand } from './base.command'; -import ConfigCommand from './config.command'; -import FlushCommand from './flush.command'; -import SyncCommand from './sync.command'; +import { BaseCommand } from './base-command'; +import ConfigCommand from './config-command'; +import FlushCommand from './flush-command'; +import SyncCommand from './sync-command'; type SettingsAction = 'config' | 'sync' | 'flush' | 'back'; diff --git a/src/cli/commands/sync.command.ts b/src/cli/commands/sync-command.ts similarity index 97% rename from src/cli/commands/sync.command.ts rename to src/cli/commands/sync-command.ts index 49b55b1..4bb3cc5 100644 --- a/src/cli/commands/sync.command.ts +++ b/src/cli/commands/sync-command.ts @@ -4,11 +4,11 @@ import chalk from 'chalk'; import fs from 'fs-extra'; import simpleGit, { SimpleGit } from 'simple-git'; -import { BaseCommand } from './base.command'; +import { BaseCommand } from './base-command'; import { getConfig, setConfig } from '../../shared/config'; -import logger from '../../shared/utils/logger.util'; -import { cliConfig } from '../cli.config'; -import { syncPromptsWithDatabase, cleanupOrphanedData } from '../utils/database.util'; +import logger from '../../shared/utils/logger'; +import { cliConfig } from '../config/cli-config'; +import { syncPromptsWithDatabase, cleanupOrphanedData } from '../utils/database'; class SyncCommand extends BaseCommand { constructor() { diff --git a/src/cli/cli.config.ts b/src/cli/config/cli-config.ts similarity index 71% rename from src/cli/cli.config.ts rename to src/cli/config/cli-config.ts index 080fe06..4427ee2 100644 --- a/src/cli/cli.config.ts +++ b/src/cli/config/cli-config.ts @@ -1,6 +1,6 @@ import * as path from 'path'; -import { CONFIG_DIR } from '../shared/config/config.constants'; +import { CONFIG_DIR } from '../../shared/config/constants'; export interface CliConfig { PROMPTS_DIR: string; @@ -11,8 +11,8 @@ export interface CliConfig { } export const cliConfig: CliConfig = { - PROMPTS_DIR: path.join(CONFIG_DIR, 'prompts'), - FRAGMENTS_DIR: path.join(CONFIG_DIR, 'fragments'), + PROMPTS_DIR: 'prompts', + FRAGMENTS_DIR: 'fragments', DB_PATH: path.join(CONFIG_DIR, 'prompts.sqlite'), TEMP_DIR: path.join(CONFIG_DIR, 'temp'), MENU_PAGE_SIZE: process.env.MENU_PAGE_SIZE ? parseInt(process.env.MENU_PAGE_SIZE, 10) : 20 diff --git a/src/cli/cli.constants.ts b/src/cli/constants.ts similarity index 100% rename from src/cli/cli.constants.ts rename to src/cli/constants.ts diff --git a/src/cli/index.ts b/src/cli/index.ts index 54b254f..6efd72e 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -4,16 +4,16 @@ import { Command } from 'commander'; import dotenv from 'dotenv'; import { getConfigValue, setConfig } from '../shared/config'; -import configCommand from './commands/config.command'; -import envCommand from './commands/env.command'; -import executeCommand from './commands/execute.command'; -import flushCommand from './commands/flush.command'; -import fragmentsCommand from './commands/fragments.command'; -import { showMainMenu } from './commands/menu.command'; -import promptsCommand from './commands/prompts.command'; -import settingsCommand from './commands/settings.command'; -import syncCommand from './commands/sync.command'; -import { initDatabase } from './utils/database.util'; +import configCommand from './commands/config-command'; +import envCommand from './commands/env-command'; +import executeCommand from './commands/execute-command'; +import flushCommand from './commands/flush-command'; +import fragmentsCommand from './commands/fragments-command'; +import { showMainMenu } from './commands/menu-command'; +import promptsCommand from './commands/prompts-command'; +import settingsCommand from './commands/settings-command'; +import syncCommand from './commands/sync-command'; +import { initDatabase } from './utils/database'; process.env.CLI_ENV = 'cli'; diff --git a/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap b/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap new file mode 100644 index 0000000..c636f79 --- /dev/null +++ b/src/cli/utils/__tests__/__snapshots__/prompts.test.ts.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PromptsUtils viewPromptDetails should display env variable correctly 1`] = ` +"Prompt: Test Prompt + +Full test description + +Category: Test + +Tags: tag1, tag2 + +Options: ([*] Required [ ] Optional) + --var1 [*] + test role + Env: env_var_name (env-value) +" +`; + +exports[`PromptsUtils viewPromptDetails should display fragment variable correctly 1`] = ` +"Prompt: Test Prompt + +Full test description + +Category: Test + +Tags: tag1, tag2 + +Options: ([*] Required [ ] Optional)" +`; + +exports[`PromptsUtils viewPromptDetails should display prompt details correctly 1`] = ` +"Prompt: Test Prompt + +Full test description + +Category: Test + +Tags: tag1, tag2 + +Options: ([*] Required [ ] Optional) + --var1 [*] (Env variable available) + test role + Not Set (Required) + --var2 [ ] + test role 2 + Not Set +" +`; + +exports[`PromptsUtils viewPromptDetails should display regular variable value correctly 1`] = ` +"Prompt: Test Prompt + +Full test description + +Category: Test + +Tags: tag1, tag2 + +Options: ([*] Required [ ] Optional)" +`; + +exports[`PromptsUtils viewPromptDetails should handle env vars fetch failure 1`] = ` +"Prompt: Test Prompt + +Full test description + +Category: Test + +Tags: tag1, tag2 + +Options: ([*] Required [ ] Optional) + --var1 [*] + test role + Not Set (Required) + --var2 [ ] + test role 2 + Not Set +" +`; diff --git a/src/cli/utils/__tests__/conversation-manager.test.ts b/src/cli/utils/__tests__/conversation-manager.test.ts new file mode 100644 index 0000000..0411ed8 --- /dev/null +++ b/src/cli/utils/__tests__/conversation-manager.test.ts @@ -0,0 +1,94 @@ +import { processPromptContent } from '../../../shared/utils/prompt-processing'; +import { ConversationManager } from '../conversation-manager'; +import { resolveInputs } from '../input-resolver'; +import { getPromptFiles } from '../prompts'; + +jest.mock('../prompts'); +jest.mock('../input-resolver'); +jest.mock('../../../shared/utils/prompt-processing'); +jest.mock('../errors', () => ({ + handleError: jest.fn() +})); + +describe('ConversationManagerUtils', () => { + let conversationManager: ConversationManager; + const mockPromptId = 'test-prompt'; + beforeEach(() => { + conversationManager = new ConversationManager(mockPromptId); + jest.clearAllMocks(); + }); + + describe('initializeConversation', () => { + it('should successfully initialize conversation with user inputs', async () => { + const mockUserInputs = { key: 'value' }; + const mockResolvedInputs = { key: 'resolved-value' }; + const mockPromptContent = 'Hello {{key}}'; + const mockResponse = 'Assistant response'; + (getPromptFiles as jest.Mock).mockResolvedValue({ + success: true, + data: { promptContent: mockPromptContent } + }); + (resolveInputs as jest.Mock).mockResolvedValue(mockResolvedInputs); + (processPromptContent as jest.Mock).mockResolvedValue(mockResponse); + + const result = await conversationManager.initializeConversation(mockUserInputs); + expect(result).toEqual({ + success: true, + data: mockResponse + }); + expect(getPromptFiles).toHaveBeenCalledWith(mockPromptId); + expect(resolveInputs).toHaveBeenCalledWith(mockUserInputs); + expect(processPromptContent).toHaveBeenCalled(); + }); + + it('should handle failed prompt files retrieval', async () => { + (getPromptFiles as jest.Mock).mockResolvedValue({ + success: false, + error: 'Failed to get prompts' + }); + + const result = await conversationManager.initializeConversation({}); + expect(result).toEqual({ + success: false, + error: 'Failed to get prompts' + }); + }); + + it('should handle errors during initialization', async () => { + const mockError = new Error('Test error'); + (getPromptFiles as jest.Mock).mockRejectedValue(mockError); + + const result = await conversationManager.initializeConversation({}); + expect(result).toEqual({ + success: false, + error: 'Failed to initialize conversation' + }); + }); + }); + + describe('continueConversation', () => { + it('should successfully continue conversation', async () => { + const mockUserInput = 'Hello'; + const mockResponse = 'Assistant response'; + (processPromptContent as jest.Mock).mockResolvedValue(mockResponse); + + const result = await conversationManager.continueConversation(mockUserInput); + expect(result).toEqual({ + success: true, + data: mockResponse + }); + expect(processPromptContent).toHaveBeenCalled(); + }); + + it('should handle errors during conversation continuation', async () => { + const mockError = new Error('Test error'); + (processPromptContent as jest.Mock).mockRejectedValue(mockError); + + const result = await conversationManager.continueConversation('test'); + expect(result).toEqual({ + success: false, + error: 'Failed to continue conversation' + }); + }); + }); +}); diff --git a/src/cli/utils/__tests__/database.test.ts b/src/cli/utils/__tests__/database.test.ts new file mode 100644 index 0000000..76f65bb --- /dev/null +++ b/src/cli/utils/__tests__/database.test.ts @@ -0,0 +1,634 @@ +import path from 'path'; + +import { jest } from '@jest/globals'; +import fs from 'fs-extra'; +import yaml from 'js-yaml'; +import NodeCache from 'node-cache'; +import sqlite3, { RunResult } from 'sqlite3'; + +import { PromptMetadata } from '../../../shared/types'; +import { fileExists, readDirectory, readFileContent } from '../../../shared/utils/file-system'; +import logger from '../../../shared/utils/logger'; +import { cliConfig } from '../../config/cli-config'; +import { + runAsync, + getAsync, + allAsync, + handleApiResult, + getCachedOrFetch, + initDatabase, + fetchCategories, + getPromptDetails, + updatePromptVariable, + syncPromptsWithDatabase, + cleanupOrphanedData, + flushData, + db, + cache +} from '../database'; +import { createPrompt } from '../prompts'; + +jest.mock('fs-extra'); +jest.mock('js-yaml'); +jest.mock('node-cache'); +jest.mock('sqlite3'); +jest.mock('../errors'); +jest.mock('../prompts'); +jest.mock('../../../shared/utils/file-system'); +jest.mock('../../../shared/utils/logger'); + +const mockFs = fs as jest.Mocked; +const mockYaml = yaml as jest.Mocked; +const mockCreatePrompt = createPrompt as jest.MockedFunction; +const mockFileExists = fileExists as jest.MockedFunction; +const mockReadDirectory = readDirectory as jest.MockedFunction; +const mockReadFileContent = readFileContent as jest.MockedFunction; +const mockLogger = logger as jest.Mocked; +describe('DatabaseUtils', () => { + let runSpy: jest.SpiedFunction; + let getSpy: jest.SpiedFunction; + let allSpy: jest.SpiedFunction; + beforeEach(() => { + jest.clearAllMocks(); + + runSpy = jest.spyOn(db, 'run').mockImplementation(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((this: RunResult, err: Error | null) => void), + callback?: (this: RunResult, err: Error | null) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback.call({ lastID: 1, changes: 1 } as RunResult, null); + } + return this; + }); + + getSpy = jest.spyOn(db, 'get').mockImplementation(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, row?: any) => void), + callback?: (err: Error | null, row?: any) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(null, { id: 1, name: 'Test' }); + } + return this; + }); + + allSpy = jest.spyOn(db, 'all').mockImplementation(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void), + callback?: (err: Error | null, rows?: any[]) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(null, [{ id: 1 }, { id: 2 }]); + } + return this; + }); + }); + + describe('runAsync', () => { + it('should execute SQL run command successfully', async () => { + const result = await runAsync('INSERT INTO test_table VALUES (?)', ['test']); + expect(result.success).toBe(true); + expect(result.data).toEqual({ lastID: 1, changes: 1 }); + }); + + it('should handle SQL run command error', async () => { + runSpy.mockImplementation(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((this: RunResult, err: Error | null) => void), + callback?: (this: RunResult, err: Error | null) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback.call({} as RunResult, new Error('SQL error')); + } + return this; + }); + + const result = await runAsync('INVALID SQL', []); + expect(result.success).toBe(false); + expect(result.error).toBe('SQL error'); + }); + }); + + describe('getAsync', () => { + it('should execute SQL get command successfully', async () => { + const result = await getAsync<{ id: number; name: string }>('SELECT * FROM test_table WHERE id = ?', [1]); + expect(result.success).toBe(true); + expect(result.data).toEqual({ id: 1, name: 'Test' }); + }); + + it('should handle SQL get command error', async () => { + getSpy.mockImplementation(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, row?: any) => void), + callback?: (err: Error | null, row?: any) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(new Error('SQL error'), undefined); + } + return this; + }); + + const result = await getAsync('INVALID SQL', []); + expect(result.success).toBe(false); + expect(result.error).toBe('SQL error'); + }); + }); + + describe('allAsync', () => { + it('should execute SQL all command successfully', async () => { + const result = await allAsync<{ id: number }>('SELECT * FROM test_table', []); + expect(result.success).toBe(true); + expect(result.data).toEqual([{ id: 1 }, { id: 2 }]); + }); + + it('should handle SQL all command error', async () => { + allSpy.mockImplementation(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void), + callback?: (err: Error | null, rows?: any[]) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(new Error('SQL error'), undefined); + } + return this; + }); + + const result = await allAsync('INVALID SQL', []); + expect(result.success).toBe(false); + expect(result.error).toBe('SQL error'); + }); + }); + + describe('handleApiResult', () => { + it('should return data if result is successful', async () => { + const result = await handleApiResult({ success: true, data: 'Test Data' }, 'Test Message'); + expect(result).toBe('Test Data'); + }); + + it('should handle error if result is not successful', async () => { + const result = await handleApiResult({ success: false, error: 'Test Error' }, 'Test Message'); + expect(result).toBeNull(); + }); + }); + + describe('getCachedOrFetch', () => { + let cacheInstance: NodeCache; + let cacheGetSpy: jest.SpiedFunction; + let cacheSetSpy: jest.SpiedFunction; + beforeEach(() => { + cacheInstance = new NodeCache(); + cacheGetSpy = jest.spyOn(cacheInstance, 'get'); + cacheSetSpy = jest.spyOn(cacheInstance, 'set'); + + (cache as any) = cacheInstance; + }); + + it('should return cached data if available', async () => { + cacheGetSpy.mockReturnValue('Cached Data'); + + const fetchFn = jest.fn(() => Promise.resolve({ success: true, data: 'Fetched Data' })); + const result = await getCachedOrFetch('testKey', fetchFn); + expect(result.success).toBe(true); + expect(result.data).toBe('Cached Data'); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('should fetch data if not in cache and cache it', async () => { + cacheGetSpy.mockReturnValue(undefined); + + const fetchFn = jest.fn(() => Promise.resolve({ success: true, data: 'Fetched Data' })); + const result = await getCachedOrFetch('testKey', fetchFn); + expect(result.success).toBe(true); + expect(result.data).toBe('Fetched Data'); + expect(fetchFn).toHaveBeenCalled(); + expect(cacheSetSpy).toHaveBeenCalledWith('testKey', 'Fetched Data'); + }); + + it('should handle fetch error', async () => { + cacheGetSpy.mockReturnValue(undefined); + + const fetchFn = jest.fn(() => Promise.resolve({ success: false, error: 'Fetch Error' })); + const result = await getCachedOrFetch('testKey', fetchFn); + expect(result.success).toBe(false); + expect(result.error).toBe('Fetch Error'); + expect(fetchFn).toHaveBeenCalled(); + expect(cacheSetSpy).not.toHaveBeenCalled(); + }); + }); + + describe('initDatabase', () => { + it('should initialize the database successfully', async () => { + mockFs.ensureDir.mockImplementation(() => Promise.resolve()); + + const result = await initDatabase(); + expect(result.success).toBe(true); + expect(mockFs.ensureDir).toHaveBeenCalledWith(path.dirname(cliConfig.DB_PATH)); + expect(runSpy).toHaveBeenCalledTimes(5); + }); + + it('should handle errors during database initialization', async () => { + mockFs.ensureDir.mockImplementation(() => Promise.reject(new Error('FS Error'))); + + const result = await initDatabase(); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to initialize database'); + }); + }); + + describe('fetchCategories', () => { + it('should fetch categories successfully', async () => { + const mockData = [ + { + id: 1, + title: 'Test Prompt', + primary_category: 'Category1', + description: 'Test Description', + path: '/test/path', + tags: 'tag1,tag2', + subcategories: 'sub1,sub2' + } + ]; + allSpy.mockImplementationOnce(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void), + callback?: (err: Error | null, rows?: any[]) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(null, mockData); + } + return this; + }); + + const result = await fetchCategories(); + expect(result.success).toBe(true); + expect(result.data).toEqual({ + Category1: [ + { + id: 1, + title: 'Test Prompt', + primary_category: 'Category1', + description: 'Test Description', + path: '/test/path', + tags: ['tag1', 'tag2'], + subcategories: ['sub1', 'sub2'] + } + ] + }); + }); + + it('should handle errors when fetching categories', async () => { + allSpy.mockImplementationOnce(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void), + callback?: (err: Error | null, rows?: any[]) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(new Error('DB Error'), undefined); + } + return this; + }); + + const result = await fetchCategories(); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to fetch prompts with categories'); + }); + }); + + describe('getPromptDetails', () => { + it('should get prompt details successfully', async () => { + getSpy.mockImplementationOnce(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, row?: any) => void), + callback?: (err: Error | null, row?: any) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(null, { + id: 1, + title: 'Test Prompt', + content: 'Test Content', + tags: ['tag1', 'tag2'] + }); + } + return this; + }); + + allSpy.mockImplementationOnce(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void), + callback?: (err: Error | null, rows?: any[]) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(null, [{ name: 'var1', role: 'user', value: '', optional_for_user: false }]); + } + return this; + }); + + const result = await getPromptDetails('1'); + expect(result.success).toBe(true); + expect(result.data).toEqual({ + id: 1, + title: 'Test Prompt', + content: 'Test Content', + tags: ['tag1', 'tag2'], + variables: [ + { + name: 'var1', + role: 'user', + value: '', + optional_for_user: false + } + ] + }); + }); + + it('should handle errors when getting prompt details', async () => { + getSpy.mockImplementationOnce(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, row?: any) => void), + callback?: (err: Error | null, row?: any) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(new Error('DB Error'), undefined); + } + return this; + }); + + const result = await getPromptDetails('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to fetch prompt details'); + }); + }); + + describe('updatePromptVariable', () => { + it('should update prompt variable successfully', async () => { + runSpy.mockImplementationOnce(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((this: RunResult, err: Error | null) => void), + callback?: (this: RunResult, err: Error | null) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback.call({ changes: 1 } as RunResult, null); + } + return this; + }); + + const result = await updatePromptVariable('1', 'var1', 'newValue'); + expect(result.success).toBe(true); + }); + + it('should handle errors when updating prompt variable', async () => { + runSpy.mockImplementationOnce(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((this: RunResult, err: Error | null) => void), + callback?: (this: RunResult, err: Error | null) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback.call({ changes: 0 } as RunResult, null); + } + return this; + }); + + const result = await updatePromptVariable('1', 'var1', 'newValue'); + expect(result.success).toBe(false); + expect(result.error).toBe('No variable found with name var1 for prompt 1'); + }); + }); + + describe('syncPromptsWithDatabase', () => { + it('should sync prompts with database successfully', async () => { + mockReadDirectory.mockResolvedValue(['prompt1', 'prompt2']); + mockFileExists.mockResolvedValue(true); + mockReadFileContent.mockResolvedValue('content'); + mockYaml.load.mockReturnValue({ + title: 'Test Prompt', + primary_category: 'Category1' + } as PromptMetadata); + + mockCreatePrompt.mockResolvedValue({ success: true }); + + const result = await syncPromptsWithDatabase(); + expect(result.success).toBe(true); + expect(runSpy).toHaveBeenNthCalledWith(1, 'DELETE FROM prompts', [], expect.any(Function)); + expect(runSpy).toHaveBeenNthCalledWith(2, 'DELETE FROM subcategories', [], expect.any(Function)); + expect(runSpy).toHaveBeenNthCalledWith(3, 'DELETE FROM variables', [], expect.any(Function)); + expect(runSpy).toHaveBeenNthCalledWith(4, 'DELETE FROM fragments', [], expect.any(Function)); + expect(mockCreatePrompt).toHaveBeenCalledTimes(2); + }); + + it('should handle errors during sync', async () => { + mockReadDirectory.mockRejectedValue(new Error('FS Error')); + + const result = await syncPromptsWithDatabase(); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to sync prompts with database'); + }); + }); + + describe('cleanupOrphanedData', () => { + it('should clean up orphaned data successfully', async () => { + mockReadDirectory.mockResolvedValue(['1', '2']); + + allSpy.mockImplementationOnce(function ( + this: sqlite3.Database, + sql: string, + paramsOrCallback?: any[] | ((err: Error | null, rows?: any[]) => void), + callback?: (err: Error | null, rows?: any[]) => void + ): sqlite3.Database { + let params: any[] | undefined; + + if (typeof paramsOrCallback === 'function') { + callback = paramsOrCallback; + params = undefined; + } else { + params = paramsOrCallback; + } + + if (callback) { + callback(null, []); + } + return this; + }); + + const result = await cleanupOrphanedData(); + expect(result.success).toBe(true); + expect(runSpy).toHaveBeenNthCalledWith( + 1, + 'DELETE FROM prompts WHERE id NOT IN (?)', + ['1,2'], + expect.any(Function) + ); + expect(runSpy).toHaveBeenNthCalledWith( + 2, + 'DELETE FROM subcategories WHERE prompt_id NOT IN (SELECT id FROM prompts)', + [], + expect.any(Function) + ); + expect(runSpy).toHaveBeenNthCalledWith( + 3, + 'DELETE FROM fragments WHERE prompt_id NOT IN (SELECT id FROM prompts)', + [], + expect.any(Function) + ); + }); + + it('should handle errors during cleanup', async () => { + mockReadDirectory.mockRejectedValue(new Error('FS Error')); + + const result = await cleanupOrphanedData(); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to clean up orphaned data'); + }); + }); + + describe('flushData', () => { + it('should flush data successfully', async () => { + mockFs.emptyDir.mockImplementation(() => Promise.resolve()); + + await flushData(); + + expect(runSpy).toHaveBeenNthCalledWith(1, 'DELETE FROM prompts', [], expect.any(Function)); + expect(runSpy).toHaveBeenNthCalledWith(2, 'DELETE FROM subcategories', [], expect.any(Function)); + expect(runSpy).toHaveBeenNthCalledWith(3, 'DELETE FROM variables', [], expect.any(Function)); + expect(runSpy).toHaveBeenNthCalledWith(4, 'DELETE FROM fragments', [], expect.any(Function)); + expect(runSpy).toHaveBeenNthCalledWith(5, 'DELETE FROM env_vars', [], expect.any(Function)); + expect(mockFs.emptyDir).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR); + }); + + it('should handle errors during data flush', async () => { + mockFs.emptyDir.mockImplementation(() => Promise.reject(new Error('FS Error'))); + + await expect(flushData()).rejects.toThrow('Failed to flush data'); + }); + }); +}); \ No newline at end of file diff --git a/src/cli/utils/__tests__/env-vars.test.ts b/src/cli/utils/__tests__/env-vars.test.ts new file mode 100644 index 0000000..9d2ef18 --- /dev/null +++ b/src/cli/utils/__tests__/env-vars.test.ts @@ -0,0 +1,178 @@ +import { EnvVar } from '../../../shared/types'; +import { runAsync, allAsync } from '../database'; +import { createEnvVar, readEnvVars, updateEnvVar, deleteEnvVar } from '../env-vars'; + +jest.mock('../database', () => ({ + runAsync: jest.fn(), + allAsync: jest.fn() +})); + +describe('EnvVarsUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('createEnvVar', () => { + it('should successfully create an environment variable', async () => { + const mockEnvVar: Omit = { + name: 'TEST_VAR', + value: 'test-value', + scope: 'global', + prompt_id: undefined + }; + (runAsync as jest.Mock).mockResolvedValue({ + success: true, + data: { lastID: 1 } + }); + + const result = await createEnvVar(mockEnvVar); + expect(result).toEqual({ + success: true, + data: { ...mockEnvVar, id: 1 } + }); + expect(runAsync).toHaveBeenCalledWith( + 'INSERT INTO env_vars (name, value, scope, prompt_id) VALUES (?, ?, ?, ?)', + ['TEST_VAR', 'test-value', 'global', null] + ); + }); + + it('should handle database errors during creation', async () => { + const mockEnvVar: Omit = { + name: 'TEST_VAR', + value: 'test-value', + scope: 'global', + prompt_id: undefined + }; + (runAsync as jest.Mock).mockRejectedValue(new Error('Database error')); + + const result = await createEnvVar(mockEnvVar); + expect(result).toEqual({ + success: false, + error: 'Failed to create environment variable' + }); + }); + }); + + describe('readEnvVars', () => { + it('should read all global environment variables', async () => { + const mockEnvVars = [ + { id: 1, name: 'TEST_VAR1', value: 'value1', scope: 'global', prompt_id: null }, + { id: 2, name: 'TEST_VAR2', value: 'value2', scope: 'global', prompt_id: null } + ]; + (allAsync as jest.Mock).mockResolvedValue({ + success: true, + data: mockEnvVars + }); + + const result = await readEnvVars(); + expect(result).toEqual({ + success: true, + data: mockEnvVars + }); + expect(allAsync).toHaveBeenCalledWith('SELECT * FROM env_vars WHERE scope = "global"', []); + }); + + it('should read environment variables for specific prompt', async () => { + const promptId = 123; + const mockEnvVars = [{ id: 1, name: 'TEST_VAR1', value: 'value1', scope: 'prompt', prompt_id: promptId }]; + (allAsync as jest.Mock).mockResolvedValue({ + success: true, + data: mockEnvVars + }); + + const result = await readEnvVars(promptId); + expect(result).toEqual({ + success: true, + data: mockEnvVars + }); + expect(allAsync).toHaveBeenCalledWith( + 'SELECT * FROM env_vars WHERE scope = "global" OR (scope = "prompt" AND prompt_id = ?)', + [promptId] + ); + }); + + it('should handle database errors during read', async () => { + (allAsync as jest.Mock).mockRejectedValue(new Error('Database error')); + + const result = await readEnvVars(); + expect(result).toEqual({ + success: false, + error: 'Failed to read environment variables' + }); + }); + + it('should handle unsuccessful database response', async () => { + (allAsync as jest.Mock).mockResolvedValue({ + success: false, + data: undefined, + error: undefined + }); + + const result = await readEnvVars(); + expect(result).toEqual({ + success: false, + error: 'Failed to fetch environment variables' + }); + }); + }); + + describe('updateEnvVar', () => { + it('should successfully update an environment variable', async () => { + (runAsync as jest.Mock).mockResolvedValue({ + success: true, + data: { changes: 1 } + }); + + const result = await updateEnvVar(1, 'new-value'); + expect(result).toEqual({ success: true }); + expect(runAsync).toHaveBeenCalledWith('UPDATE env_vars SET value = ? WHERE id = ?', ['new-value', 1]); + }); + + it('should handle non-existent environment variable', async () => { + (runAsync as jest.Mock).mockResolvedValue({ + success: true, + data: { changes: 0 } + }); + + const result = await updateEnvVar(999, 'new-value'); + expect(result).toEqual({ + success: false, + error: 'No environment variable found with id 999' + }); + }); + + it('should handle database errors during update', async () => { + (runAsync as jest.Mock).mockRejectedValue(new Error('Database error')); + + const result = await updateEnvVar(1, 'new-value'); + expect(result).toEqual({ + success: false, + error: 'Failed to update environment variable' + }); + }); + }); + + describe('deleteEnvVar', () => { + it('should successfully delete an environment variable', async () => { + (runAsync as jest.Mock).mockResolvedValue({ + success: true + }); + + const result = await deleteEnvVar(1); + expect(result).toEqual({ success: true }); + expect(runAsync).toHaveBeenCalledWith('DELETE FROM env_vars WHERE id = ?', [1]); + }); + + it('should handle database errors during deletion', async () => { + (runAsync as jest.Mock).mockRejectedValue(new Error('Database error')); + + const result = await deleteEnvVar(1); + expect(result).toEqual({ + success: false, + error: 'Failed to delete environment variable' + }); + }); + }); +}); diff --git a/src/cli/utils/__tests__/errors.test.ts b/src/cli/utils/__tests__/errors.test.ts new file mode 100644 index 0000000..99faca4 --- /dev/null +++ b/src/cli/utils/__tests__/errors.test.ts @@ -0,0 +1,76 @@ +import chalk from 'chalk'; + +import logger from '../../../shared/utils/logger'; +import { AppError, handleError } from '../errors'; + +jest.mock('../../../shared/utils/logger'); + +describe('ErrorsUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('AppError', () => { + it('should create an AppError with code and message', () => { + const error = new AppError('TEST_ERROR', 'Test error message'); + expect(error).toBeInstanceOf(Error); + expect(error).toBeInstanceOf(AppError); + expect(error.code).toBe('TEST_ERROR'); + expect(error.message).toBe('Test error message'); + expect(error.name).toBe('AppError'); + }); + }); + + describe('handleError', () => { + const context = 'test context'; + it('should handle AppError', () => { + const error = new AppError('TEST_ERROR', 'Test error message'); + handleError(error, context); + + expect(logger.error).toHaveBeenCalledWith('Error in test context:'); + expect(logger.error).toHaveBeenCalledWith('[TEST_ERROR] Test error message'); + expect(console.error).toHaveBeenCalledWith( + chalk.red('Error in test context: [TEST_ERROR] Test error message') + ); + }); + + it('should handle standard Error with stack trace', () => { + const error = new Error('Standard error'); + handleError(error, context); + + expect(logger.error).toHaveBeenCalledWith('Error in test context:'); + expect(logger.error).toHaveBeenCalledWith(error.message); + expect(logger.debug).toHaveBeenCalledWith('Stack trace:', error.stack); + expect(console.error).toHaveBeenCalledWith(chalk.red(' Message: Standard error')); + expect(console.error).toHaveBeenCalledWith(chalk.yellow(' Stack trace:')); + }); + + it('should handle string error', () => { + const errorMessage = 'String error message'; + handleError(errorMessage, context); + + expect(logger.error).toHaveBeenCalledWith('Error in test context:'); + expect(logger.error).toHaveBeenCalledWith(errorMessage); + expect(console.error).toHaveBeenCalledWith(chalk.red(` ${errorMessage}`)); + }); + + it('should handle unknown error type', () => { + const error = { custom: 'error' }; + handleError(error, context); + + expect(logger.error).toHaveBeenCalledWith('Error in test context:'); + expect(logger.error).toHaveBeenCalledWith(`Unknown error: ${JSON.stringify(error)}`); + expect(console.error).toHaveBeenCalledWith(chalk.red(` Unknown error: ${JSON.stringify(error)}`)); + }); + + it('should always show the report message', () => { + handleError('any error', context); + + expect(console.error).toHaveBeenCalledWith( + chalk.cyan('\nIf this error persists, please report it to the development team.') + ); + }); + }); +}); diff --git a/src/cli/utils/__tests__/file-system.test.ts b/src/cli/utils/__tests__/file-system.test.ts new file mode 100644 index 0000000..e1f7371 --- /dev/null +++ b/src/cli/utils/__tests__/file-system.test.ts @@ -0,0 +1,77 @@ +import fs from 'fs-extra'; + +import { readDirectory } from '../../../shared/utils/file-system'; +import { cliConfig } from '../../config/cli-config'; +import { hasPrompts, hasFragments } from '../file-system'; + +jest.mock('fs-extra'); +jest.mock('../../../shared/utils/file-system'); +jest.mock('../errors', () => ({ + handleError: jest.fn() +})); + +describe('FileSystemUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('hasPrompts', () => { + it('should return true when prompts directory has contents', async () => { + (fs.ensureDir as jest.Mock).mockResolvedValue(undefined); + (readDirectory as jest.Mock).mockResolvedValue(['prompt1', 'prompt2']); + + const result = await hasPrompts(); + expect(result).toBe(true); + expect(fs.ensureDir).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR); + expect(readDirectory).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR); + }); + + it('should return false when prompts directory is empty', async () => { + (fs.ensureDir as jest.Mock).mockResolvedValue(undefined); + (readDirectory as jest.Mock).mockResolvedValue([]); + + const result = await hasPrompts(); + expect(result).toBe(false); + expect(fs.ensureDir).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR); + expect(readDirectory).toHaveBeenCalledWith(cliConfig.PROMPTS_DIR); + }); + + it('should handle errors and return false', async () => { + const error = new Error('Test error'); + (fs.ensureDir as jest.Mock).mockRejectedValue(error); + + const result = await hasPrompts(); + expect(result).toBe(false); + }); + }); + + describe('hasFragments', () => { + it('should return true when fragments directory has contents', async () => { + (fs.ensureDir as jest.Mock).mockResolvedValue(undefined); + (readDirectory as jest.Mock).mockResolvedValue(['fragment1', 'fragment2']); + + const result = await hasFragments(); + expect(result).toBe(true); + expect(fs.ensureDir).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR); + expect(readDirectory).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR); + }); + + it('should return false when fragments directory is empty', async () => { + (fs.ensureDir as jest.Mock).mockResolvedValue(undefined); + (readDirectory as jest.Mock).mockResolvedValue([]); + + const result = await hasFragments(); + expect(result).toBe(false); + expect(fs.ensureDir).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR); + expect(readDirectory).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR); + }); + + it('should handle errors and return false', async () => { + const error = new Error('Test error'); + (fs.ensureDir as jest.Mock).mockRejectedValue(error); + + const result = await hasFragments(); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/cli/utils/__tests__/fragments.test.ts b/src/cli/utils/__tests__/fragments.test.ts new file mode 100644 index 0000000..bf151c5 --- /dev/null +++ b/src/cli/utils/__tests__/fragments.test.ts @@ -0,0 +1,84 @@ +import path from 'path'; + +import { jest } from '@jest/globals'; + +import { Fragment } from '../../../shared/types'; +import { readDirectory, readFileContent } from '../../../shared/utils/file-system'; +import { cliConfig } from '../../config/cli-config'; +import { listFragments, viewFragmentContent } from '../fragments'; + +jest.mock('../../../shared/utils/file-system'); +jest.mock('../errors', () => ({ + handleError: jest.fn() +})); + +describe('FragmentsUtils', () => { + const mockReadDirectory = readDirectory as jest.MockedFunction; + const mockReadFileContent = readFileContent as jest.MockedFunction; + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('listFragments', () => { + it('should successfully list fragments', async () => { + mockReadDirectory + .mockResolvedValueOnce(['category1', 'category2']) + .mockResolvedValueOnce(['fragment1.md', 'fragment2.md']) + .mockResolvedValueOnce(['fragment3.md']); + + const expectedFragments: Fragment[] = [ + { category: 'category1', name: 'fragment1', variable: '' }, + { category: 'category1', name: 'fragment2', variable: '' }, + { category: 'category2', name: 'fragment3', variable: '' } + ]; + const result = await listFragments(); + expect(result.success).toBe(true); + expect(result.data).toEqual(expectedFragments); + expect(mockReadDirectory).toHaveBeenCalledWith(cliConfig.FRAGMENTS_DIR); + }); + + it('should handle errors when listing fragments', async () => { + mockReadDirectory.mockRejectedValueOnce(new Error('Directory read error')); + + const result = await listFragments(); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to list fragments'); + }); + + it('should ignore non-markdown files', async () => { + mockReadDirectory + .mockResolvedValueOnce(['category1']) + .mockResolvedValueOnce(['fragment1.md', 'fragment2.txt', 'fragment3.md']); + + const expectedFragments: Fragment[] = [ + { category: 'category1', name: 'fragment1', variable: '' }, + { category: 'category1', name: 'fragment3', variable: '' } + ]; + const result = await listFragments(); + expect(result.success).toBe(true); + expect(result.data).toEqual(expectedFragments); + }); + }); + + describe('viewFragmentContent', () => { + it('should successfully read fragment content', async () => { + const mockContent = '# Fragment Content'; + mockReadFileContent.mockResolvedValueOnce(mockContent); + + const result = await viewFragmentContent('category1', 'fragment1'); + expect(result.success).toBe(true); + expect(result.data).toBe(mockContent); + expect(mockReadFileContent).toHaveBeenCalledWith( + path.join(cliConfig.FRAGMENTS_DIR, 'category1', 'fragment1.md') + ); + }); + + it('should handle errors when reading fragment content', async () => { + mockReadFileContent.mockRejectedValueOnce(new Error('File read error')); + + const result = await viewFragmentContent('category1', 'fragment1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to view fragment content'); + }); + }); +}); diff --git a/src/cli/utils/__tests__/input-resolver.test.ts b/src/cli/utils/__tests__/input-resolver.test.ts new file mode 100644 index 0000000..bc26d12 --- /dev/null +++ b/src/cli/utils/__tests__/input-resolver.test.ts @@ -0,0 +1,122 @@ +import { jest } from '@jest/globals'; + +import { EnvVar } from '../../../shared/types'; +import { FRAGMENT_PREFIX, ENV_PREFIX } from '../../constants'; +import { readEnvVars } from '../env-vars'; +import { viewFragmentContent } from '../fragments'; +import { resolveValue, resolveInputs } from '../input-resolver'; + +jest.mock('../env-vars'); +jest.mock('../fragments'); +jest.mock('../errors', () => ({ + handleError: jest.fn() +})); + +describe('InputResolverUtils', () => { + const mockReadEnvVars = readEnvVars as jest.MockedFunction; + const mockViewFragmentContent = viewFragmentContent as jest.MockedFunction; + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('resolveValue', () => { + const mockEnvVars: EnvVar[] = [ + { id: 1, name: 'TEST_VAR', value: 'test-value', scope: 'global' }, + { id: 2, name: 'NESTED_VAR', value: '$env:TEST_VAR', scope: 'global' } + ]; + it('should resolve fragment references', async () => { + const fragmentContent = '# Test Fragment Content'; + mockViewFragmentContent.mockResolvedValueOnce({ + success: true, + data: fragmentContent + }); + + const result = await resolveValue(`${FRAGMENT_PREFIX}category/fragment`, mockEnvVars); + expect(result).toBe(fragmentContent); + expect(mockViewFragmentContent).toHaveBeenCalledWith('category', 'fragment'); + }); + + it('should handle failed fragment resolution', async () => { + mockViewFragmentContent.mockResolvedValueOnce({ + success: false, + error: 'Fragment not found' + }); + + const value = `${FRAGMENT_PREFIX}category/nonexistent`; + const result = await resolveValue(value, mockEnvVars); + expect(result).toBe(value); + }); + + it('should resolve environment variables', async () => { + const result = await resolveValue(`${ENV_PREFIX}TEST_VAR`, mockEnvVars); + expect(result).toBe('test-value'); + }); + + it('should handle nested environment variables', async () => { + const result = await resolveValue(`${ENV_PREFIX}NESTED_VAR`, mockEnvVars); + expect(result).toBe('test-value'); + }); + + it('should handle non-existent environment variables', async () => { + const value = `${ENV_PREFIX}NONEXISTENT`; + const result = await resolveValue(value, mockEnvVars); + expect(result).toBe(value); + }); + + it('should return plain values unchanged', async () => { + const value = 'plain-value'; + const result = await resolveValue(value, mockEnvVars); + expect(result).toBe(value); + }); + }); + + describe('resolveInputs', () => { + it('should resolve multiple inputs', async () => { + const inputs = { + fragment: `${FRAGMENT_PREFIX}category/fragment`, + env: `${ENV_PREFIX}TEST_VAR`, + plain: 'plain-value' + }; + mockReadEnvVars.mockResolvedValueOnce({ + success: true, + data: [{ id: 1, name: 'TEST_VAR', value: 'test-value', scope: 'global' }] + }); + + mockViewFragmentContent.mockResolvedValueOnce({ + success: true, + data: 'fragment-content' + }); + + const result = await resolveInputs(inputs); + expect(result).toEqual({ + fragment: 'fragment-content', + env: 'test-value', + plain: 'plain-value' + }); + }); + + it('should handle env vars fetch failure', async () => { + mockReadEnvVars.mockResolvedValueOnce({ + success: false, + error: 'Failed to fetch env vars' + }); + + const inputs = { + plain: 'value' + }; + const result = await resolveInputs(inputs); + expect(result).toEqual({ + plain: 'value' + }); + }); + + it('should handle resolution errors', async () => { + mockReadEnvVars.mockRejectedValueOnce(new Error('Failed to fetch env vars')); + + const inputs = { + test: 'value' + }; + await expect(resolveInputs(inputs)).rejects.toThrow(); + }); + }); +}); diff --git a/src/cli/utils/__tests__/prompts.test.ts b/src/cli/utils/__tests__/prompts.test.ts new file mode 100644 index 0000000..58a7c29 --- /dev/null +++ b/src/cli/utils/__tests__/prompts.test.ts @@ -0,0 +1,453 @@ +import { RunResult } from 'sqlite3'; + +import { PromptMetadata, Variable } from '../../../shared/types'; +import { ENV_PREFIX, FRAGMENT_PREFIX } from '../../constants'; +import { allAsync, getAsync, runAsync } from '../database'; +import { readEnvVars } from '../env-vars'; +import { createPrompt, listPrompts, getPromptFiles, getPromptMetadata, viewPromptDetails } from '../prompts'; + +jest.mock('../database'); +jest.mock('../env-vars'); +jest.mock('chalk', () => ({ + cyan: jest.fn((text) => text), + green: jest.fn((text) => text), + blue: jest.fn((text) => text), + magenta: jest.fn((text) => text), + yellow: jest.fn((text) => text), + red: jest.fn((text) => text) +})); +jest.mock('../errors', () => ({ + handleError: jest.fn() +})); + +const mockMetadata: PromptMetadata = { + title: 'Test Prompt', + primary_category: 'test', + subcategories: ['sub1', 'sub2'], + directory: 'test-dir', + tags: ['tag1', 'tag2'], + one_line_description: 'Test description', + description: 'Full test description', + variables: [ + { name: 'var1', role: 'test role', optional_for_user: false }, + { name: 'var2', role: 'test role 2', optional_for_user: true } + ], + content_hash: 'test-hash', + fragments: [{ category: 'test', name: 'fragment1', variable: '{{var1}}' }] +}; +describe('PromptsUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + describe('createPrompt', () => { + it('should successfully create a prompt with all metadata', async () => { + const mockRunAsync = runAsync as jest.MockedFunction; + mockRunAsync.mockResolvedValueOnce({ + success: true, + data: { + lastID: 1, + changes: 1 + } as unknown as RunResult + }); + + mockRunAsync.mockResolvedValue({ + success: true, + data: { + lastID: 1, + changes: 1 + } as unknown as RunResult + }); + + const result = await createPrompt(mockMetadata, 'Test content'); + expect(result.success).toBe(true); + expect(mockRunAsync).toHaveBeenCalledTimes(6); + expect(mockRunAsync).toHaveBeenCalledWith( + expect.stringContaining('INSERT INTO prompts'), + expect.arrayContaining([mockMetadata.title]) + ); + }); + + it('should handle database errors gracefully', async () => { + const mockRunAsync = runAsync as jest.MockedFunction; + mockRunAsync.mockResolvedValueOnce({ success: false, error: 'DB error' }); + + const result = await createPrompt(mockMetadata, 'Test content'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to insert prompt'); + }); + + it('should handle exceptions during prompt creation', async () => { + const mockRunAsync = runAsync as jest.MockedFunction; + mockRunAsync.mockRejectedValueOnce(new Error('Database error')); + + const result = await createPrompt(mockMetadata, 'Test content'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to create prompt'); + }); + }); + + describe('listPrompts', () => { + it('should return list of prompts successfully', async () => { + const mockPrompts = [ + { id: 1, title: 'Prompt 1', primary_category: 'cat1' }, + { id: 2, title: 'Prompt 2', primary_category: 'cat2' } + ]; + const mockAllAsync = allAsync as jest.MockedFunction; + mockAllAsync.mockResolvedValueOnce({ success: true, data: mockPrompts }); + + const result = await listPrompts(); + expect(result.success).toBe(true); + expect(result.data).toEqual(mockPrompts); + }); + + it('should handle empty results', async () => { + const mockAllAsync = allAsync as jest.MockedFunction; + mockAllAsync.mockResolvedValueOnce({ success: true, data: [] }); + + const result = await listPrompts(); + expect(result.success).toBe(true); + expect(result.data).toEqual([]); + }); + + it('should handle errors', async () => { + const mockAllAsync = allAsync as jest.MockedFunction; + mockAllAsync.mockResolvedValueOnce({ success: false, error: 'DB error' }); + + const result = await listPrompts(); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to list prompts'); + }); + + it('should return error when listing prompts fails', async () => { + const mockAllAsync = allAsync as jest.MockedFunction; + mockAllAsync.mockRejectedValueOnce(new Error('Database error')); + + const result = await listPrompts(); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to list prompts'); + }); + }); + + describe('getPromptFiles', () => { + it('should return prompt content and metadata', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + const mockAllAsync = allAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: { content: 'Test content' } + }); + + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: { + id: 1, + title: 'Test Prompt', + primary_category: 'test', + directory: 'test-dir', + one_line_description: 'Test description', + description: 'Full test description', + content_hash: 'test-hash', + tags: 'tag1,tag2' + } + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: [{ name: 'sub1' }, { name: 'sub2' }] + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: [ + { name: 'var1', role: 'test role', optional_for_user: false }, + { name: 'var2', role: 'test role 2', optional_for_user: true } + ] + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: [{ category: 'test', name: 'fragment1', variable: 'var1' }] + }); + + const result = await getPromptFiles('1'); + expect(result.success).toBe(true); + expect(result.data).toHaveProperty('promptContent', 'Test content'); + expect(result.data).toHaveProperty('metadata'); + }); + + it('should return error when prompt content is not found', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: false, + error: 'Content not found' + }); + + const result = await getPromptFiles('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Prompt not found'); + }); + + it('should return error when metadata retrieval fails', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + const mockAllAsync = allAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: { content: 'Test content' } + }); + + mockGetAsync.mockResolvedValueOnce({ + success: false, + error: 'Metadata not found' + }); + + const result = await getPromptFiles('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to get prompt metadata'); + }); + + it('should return error when prompt content data is undefined', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: undefined + }); + + const result = await getPromptFiles('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Prompt not found'); + }); + }); + + describe('getPromptMetadata', () => { + it('should return error when prompt metadata is not found', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: false, + error: 'Prompt not found' + }); + + const result = await getPromptMetadata('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Metadata not found'); + }); + + it('should return error when subcategories retrieval fails', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + const mockAllAsync = allAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: mockMetadata + }); + + mockAllAsync.mockResolvedValueOnce({ + success: false, + error: 'Failed to get subcategories' + }); + + const result = await getPromptMetadata('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to get subcategories'); + }); + + it('should return error when prompt metadata data is undefined', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: undefined + }); + + const result = await getPromptMetadata('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Metadata not found'); + }); + + it('should return error when subcategories data is undefined', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + const mockAllAsync = allAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: mockMetadata + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: undefined + }); + + const result = await getPromptMetadata('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to get subcategories'); + }); + + it('should return error when variables data is undefined', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + const mockAllAsync = allAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: mockMetadata + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: [{ name: 'sub1' }, { name: 'sub2' }] + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: undefined + }); + + const result = await getPromptMetadata('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to get variables'); + }); + + it('should return error when fragments data is undefined', async () => { + const mockGetAsync = getAsync as jest.MockedFunction; + const mockAllAsync = allAsync as jest.MockedFunction; + mockGetAsync.mockResolvedValueOnce({ + success: true, + data: mockMetadata + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: [{ name: 'sub1' }, { name: 'sub2' }] + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: [ + { name: 'var1', role: 'test role', optional_for_user: false }, + { name: 'var2', role: 'test role 2', optional_for_user: true } + ] + }); + + mockAllAsync.mockResolvedValueOnce({ + success: true, + data: undefined + }); + + const result = await getPromptMetadata('1'); + expect(result.success).toBe(false); + expect(result.error).toBe('Failed to get fragments'); + }); + }); + + describe('viewPromptDetails', () => { + const mockPrompt: PromptMetadata = { + id: '1', + title: 'Test Prompt', + primary_category: 'test', + subcategories: ['sub1', 'sub2'], + directory: 'test-dir', + tags: ['tag1', 'tag2'], + one_line_description: 'Test description', + description: 'Full test description', + variables: [ + { name: 'var1', role: 'test role', optional_for_user: false }, + { name: 'var2', role: 'test role 2', optional_for_user: true } + ], + content_hash: 'test-hash', + fragments: [{ category: 'test', name: 'fragment1', variable: 'var1' }] + }; + let consoleOutput: string[]; + beforeEach(() => { + jest.clearAllMocks(); + consoleOutput = []; + jest.spyOn(console, 'log').mockImplementation((...args) => { + consoleOutput.push(args.join(' ')); + }); + }); + + it('should display prompt details correctly', async () => { + const mockReadEnvVars = readEnvVars as jest.MockedFunction; + mockReadEnvVars.mockResolvedValueOnce({ + success: true, + data: [{ id: 1, name: 'var1', value: 'test-value', scope: 'global' }] + }); + + await viewPromptDetails(mockPrompt); + + expect(consoleOutput.join('\n')).toMatchSnapshot(); + }); + + it('should handle env vars fetch failure', async () => { + const mockReadEnvVars = readEnvVars as jest.MockedFunction; + mockReadEnvVars.mockResolvedValueOnce({ success: false, error: 'Failed to read env vars' }); + + await viewPromptDetails(mockPrompt); + + expect(consoleOutput.join('\n')).toMatchSnapshot(); + }); + + it('should display fragment variable correctly', async () => { + const mockPromptWithFragment: PromptMetadata & { variables: Variable[] } = { + ...mockPrompt, + variables: [ + { + name: 'var1', + role: 'test role', + optional_for_user: false, + value: `${FRAGMENT_PREFIX}fragmentName` + } + ] + }; + await viewPromptDetails(mockPromptWithFragment); + + expect(consoleOutput.join('\n')).toMatchSnapshot(); + }); + + it('should display env variable correctly', async () => { + const mockPromptWithEnvVar: PromptMetadata & { variables: Variable[] } = { + ...mockPrompt, + variables: [ + { + name: 'var1', + role: 'test role', + optional_for_user: false, + value: `${ENV_PREFIX}ENV_VAR_NAME` + } + ] + }; + const mockReadEnvVars = readEnvVars as jest.MockedFunction; + mockReadEnvVars.mockResolvedValueOnce({ + success: true, + data: [{ id: 1, name: 'ENV_VAR_NAME', value: 'env-value', scope: 'global' }] + }); + + await viewPromptDetails(mockPromptWithEnvVar); + + expect(consoleOutput.join('\n')).toMatchSnapshot(); + }); + + it('should display regular variable value correctly', async () => { + const mockPromptWithValue: PromptMetadata & { variables: Variable[] } = { + ...mockPrompt, + variables: [ + { + name: 'var1', + role: 'test role', + optional_for_user: false, + value: 'regular value' + } + ] + }; + await viewPromptDetails(mockPromptWithValue); + + expect(consoleOutput.join('\n')).toMatchSnapshot(); + }); + + it('should not display variable status when isExecute is true', async () => { + await viewPromptDetails(mockPrompt, true); + + const output = consoleOutput.join('\n'); + expect(output).not.toContain('Not Set'); + expect(output).not.toContain('Set:'); + }); + }); +}); diff --git a/src/cli/utils/conversation_manager.util.ts b/src/cli/utils/conversation-manager.ts similarity index 92% rename from src/cli/utils/conversation_manager.util.ts rename to src/cli/utils/conversation-manager.ts index 8daaf7d..b62b641 100644 --- a/src/cli/utils/conversation_manager.util.ts +++ b/src/cli/utils/conversation-manager.ts @@ -1,8 +1,8 @@ -import { handleError } from './error.util'; -import { resolveInputs } from './input_resolution.util'; -import { getPromptFiles } from './prompt_crud.util'; +import { handleError } from './errors'; +import { resolveInputs } from './input-resolver'; +import { getPromptFiles } from './prompts'; import { ApiResult } from '../../shared/types'; -import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt_processing.util'; +import { processPromptContent, updatePromptWithVariables } from '../../shared/utils/prompt-processing'; interface ConversationMessage { role: 'user' | 'assistant'; diff --git a/src/cli/utils/database.util.ts b/src/cli/utils/database.ts similarity index 93% rename from src/cli/utils/database.util.ts rename to src/cli/utils/database.ts index 45e2fbf..cbfcc96 100644 --- a/src/cli/utils/database.util.ts +++ b/src/cli/utils/database.ts @@ -5,16 +5,17 @@ import yaml from 'js-yaml'; import NodeCache from 'node-cache'; import sqlite3, { RunResult } from 'sqlite3'; -import { AppError, handleError } from './error.util'; -import { createPrompt } from './prompt_crud.util'; -import { commonConfig } from '../../shared/config/common.config'; -import { ApiResult, CategoryItem, Metadata, Prompt, Variable } from '../../shared/types'; -import { fileExists, readDirectory, readFileContent } from '../../shared/utils/file_system.util'; -import logger from '../../shared/utils/logger.util'; -import { cliConfig } from '../cli.config'; +import { AppError, handleError } from './errors'; +import { createPrompt } from './prompts'; +import { commonConfig } from '../../shared/config/common-config'; +import { ApiResult, CategoryItem, PromptMetadata, Variable } from '../../shared/types'; +import { fileExists, readDirectory, readFileContent } from '../../shared/utils/file-system'; +import logger from '../../shared/utils/logger'; +import { cliConfig } from '../config/cli-config'; const db = new sqlite3.Database(cliConfig.DB_PATH); -const cache = new NodeCache({ stdTTL: 600 }); + +export const cache = new NodeCache({ stdTTL: 600 }); export async function runAsync(sql: string, params: any[] = []): Promise> { return new Promise((resolve) => { @@ -68,7 +69,7 @@ export async function handleApiResult(result: ApiResult, message: string): export async function getCachedOrFetch(key: string, fetchFn: () => Promise>): Promise> { const cachedResult = cache.get(key); - if (cachedResult) { + if (cachedResult !== undefined) { return { success: true, data: cachedResult }; } @@ -177,8 +178,10 @@ export async function fetchCategories(): Promise> { - const promptResult = await getAsync('SELECT * FROM prompts WHERE id = ?', [promptId]); +export async function getPromptDetails( + promptId: string +): Promise> { + const promptResult = await getAsync('SELECT * FROM prompts WHERE id = ?', [promptId]); const variablesResult = await allAsync( 'SELECT name, role, value, optional_for_user FROM variables WHERE prompt_id = ?', [promptId] @@ -189,10 +192,7 @@ export async function getPromptDetails(promptId: string): Promise tag.trim()) - : promptResult.data.tags || []; + promptResult.data.tags = promptResult.data.tags || []; } return { success: true, @@ -253,7 +253,7 @@ export async function syncPromptsWithDatabase(): Promise> { const metadataContent = await readFileContent(metadataPath); try { - const metadata = yaml.load(metadataContent) as Metadata; + const metadata = yaml.load(metadataContent) as PromptMetadata; await createPrompt(metadata, promptContent); logger.info(`Successfully processed prompt: ${dir}`); } catch (error) { diff --git a/src/cli/utils/env.util.ts b/src/cli/utils/env-vars.ts similarity index 96% rename from src/cli/utils/env.util.ts rename to src/cli/utils/env-vars.ts index 3898294..529f2c3 100644 --- a/src/cli/utils/env.util.ts +++ b/src/cli/utils/env-vars.ts @@ -1,5 +1,5 @@ -import { runAsync, allAsync } from './database.util'; -import { handleError } from './error.util'; +import { runAsync, allAsync } from './database'; +import { handleError } from './errors'; import { EnvVar, ApiResult } from '../../shared/types'; export async function createEnvVar(envVar: Omit): Promise> { diff --git a/src/cli/utils/error.util.ts b/src/cli/utils/errors.ts similarity index 96% rename from src/cli/utils/error.util.ts rename to src/cli/utils/errors.ts index c3240d4..f535ad3 100644 --- a/src/cli/utils/error.util.ts +++ b/src/cli/utils/errors.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; -import logger from '../../shared/utils/logger.util'; +import logger from '../../shared/utils/logger'; export class AppError extends Error { constructor( diff --git a/src/cli/utils/file_system.util.ts b/src/cli/utils/file-system.ts similarity index 82% rename from src/cli/utils/file_system.util.ts rename to src/cli/utils/file-system.ts index 0513eda..c2a6bbe 100644 --- a/src/cli/utils/file_system.util.ts +++ b/src/cli/utils/file-system.ts @@ -1,8 +1,8 @@ import fs from 'fs-extra'; -import { handleError } from './error.util'; -import { readDirectory } from '../../shared/utils/file_system.util'; -import { cliConfig } from '../cli.config'; +import { handleError } from './errors'; +import { readDirectory } from '../../shared/utils/file-system'; +import { cliConfig } from '../config/cli-config'; export async function hasPrompts(): Promise { try { diff --git a/src/cli/utils/fragment_operations.util.ts b/src/cli/utils/fragments.ts similarity index 93% rename from src/cli/utils/fragment_operations.util.ts rename to src/cli/utils/fragments.ts index 5b6bdc2..c991278 100644 --- a/src/cli/utils/fragment_operations.util.ts +++ b/src/cli/utils/fragments.ts @@ -1,9 +1,9 @@ import path from 'path'; -import { handleError } from './error.util'; +import { handleError } from './errors'; import { ApiResult, Fragment } from '../../shared/types'; -import { readDirectory, readFileContent } from '../../shared/utils/file_system.util'; -import { cliConfig } from '../cli.config'; +import { readDirectory, readFileContent } from '../../shared/utils/file-system'; +import { cliConfig } from '../config/cli-config'; export async function listFragments(): Promise> { try { diff --git a/src/cli/utils/input_resolution.util.ts b/src/cli/utils/input-resolver.ts similarity index 77% rename from src/cli/utils/input_resolution.util.ts rename to src/cli/utils/input-resolver.ts index f4aeb43..cd881a7 100644 --- a/src/cli/utils/input_resolution.util.ts +++ b/src/cli/utils/input-resolver.ts @@ -1,9 +1,9 @@ import { EnvVar } from '../../shared/types'; -import logger from '../../shared/utils/logger.util'; -import { FRAGMENT_PREFIX, ENV_PREFIX } from '../cli.constants'; -import { readEnvVars } from './env.util'; -import { handleError } from './error.util'; -import { viewFragmentContent } from './fragment_operations.util'; +import logger from '../../shared/utils/logger'; +import { FRAGMENT_PREFIX, ENV_PREFIX } from '../constants'; +import { readEnvVars } from './env-vars'; +import { handleError } from './errors'; +import { viewFragmentContent } from './fragments'; export async function resolveValue(value: string, envVars: EnvVar[]): Promise { if (value.startsWith(FRAGMENT_PREFIX)) { @@ -21,7 +21,12 @@ export async function resolveValue(value: string, envVars: EnvVar[]): Promise v.name === envVarName); if (actualEnvVar) { - return await resolveValue(actualEnvVar.value, envVars); + const envVarValue = actualEnvVar.value; + + if (envVarValue.startsWith('$env:')) { + return await resolveValue(`${ENV_PREFIX}${envVarValue.slice(5)}`, envVars); + } + return envVarValue; } else { logger.warn(`Env var not found: ${envVarName}`); return value; diff --git a/src/cli/utils/metadata.util.ts b/src/cli/utils/metadata.util.ts deleted file mode 100644 index f5f1d0d..0000000 --- a/src/cli/utils/metadata.util.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { getAsync, allAsync } from './database.util'; -import { handleError } from './error.util'; -import { ApiResult, Fragment, Metadata } from '../../shared/types'; - -export async function getPromptMetadata(promptId: string): Promise> { - try { - const promptResult = await getAsync<{ - id: number; - title: string; - primary_category: string; - directory: string; - one_line_description: string; - description: string; - content_hash: string; - tags: string; - }>('SELECT * FROM prompts WHERE id = ?', [promptId]); - - if (!promptResult.success || !promptResult.data) { - return { success: false, error: 'Prompt not found' }; - } - - const prompt = promptResult.data; - const subcategoriesResult = await allAsync<{ name: string }>( - 'SELECT name FROM subcategories WHERE prompt_id = ?', - [promptId] - ); - const variablesResult = await allAsync<{ name: string; role: string; optional_for_user: boolean }>( - 'SELECT name, role, optional_for_user FROM variables WHERE prompt_id = ?', - [promptId] - ); - const fragmentsResult = await allAsync( - 'SELECT category, name, variable FROM fragments WHERE prompt_id = ?', - [promptId] - ); - const metadata: Metadata = { - title: prompt.title, - primary_category: prompt.primary_category, - subcategories: subcategoriesResult.success ? (subcategoriesResult.data?.map((s) => s.name) ?? []) : [], - directory: prompt.directory, - tags: prompt.tags ? prompt.tags.split(',') : [], - one_line_description: prompt.one_line_description, - description: prompt.description, - variables: variablesResult.success ? (variablesResult.data ?? []) : [], - content_hash: prompt.content_hash, - fragments: fragmentsResult.success ? (fragmentsResult.data ?? []) : [] - }; - return { success: true, data: metadata }; - } catch (error) { - handleError(error, 'getting prompt metadata'); - return { success: false, error: 'Failed to get prompt metadata' }; - } -} diff --git a/src/cli/utils/prompt_crud.util.ts b/src/cli/utils/prompt_crud.util.ts deleted file mode 100644 index 7450745..0000000 --- a/src/cli/utils/prompt_crud.util.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { allAsync, getAsync, runAsync } from './database.util'; -import { handleError } from './error.util'; -import { getPromptMetadata } from './metadata.util'; -import { ApiResult, Metadata, Prompt } from '../../shared/types'; - -export async function createPrompt(metadata: Metadata, content: string): Promise> { - try { - const result = await runAsync( - 'INSERT INTO prompts (title, content, primary_category, directory, one_line_description, description, content_hash, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', - [ - metadata.title, - content, - metadata.primary_category, - metadata.directory, - metadata.one_line_description, - metadata.description, - metadata.content_hash, - metadata.tags.join(',') - ] - ); - const promptId = result.data?.lastID; - - if (!promptId) { - return { success: false, error: 'Failed to insert prompt' }; - } - - for (const subcategory of metadata.subcategories) { - await runAsync('INSERT INTO subcategories (prompt_id, name) VALUES (?, ?)', [promptId, subcategory]); - } - - for (const variable of metadata.variables) { - await runAsync('INSERT INTO variables (prompt_id, name, role, optional_for_user) VALUES (?, ?, ?, ?)', [ - promptId, - variable.name, - variable.role, - variable.optional_for_user - ]); - } - - for (const fragment of metadata.fragments || []) { - await runAsync('INSERT INTO fragments (prompt_id, category, name, variable) VALUES (?, ?, ?, ?)', [ - promptId, - fragment.category, - fragment.name, - fragment.variable - ]); - } - return { success: true }; - } catch (error) { - handleError(error, 'creating prompt'); - return { success: false, error: 'Failed to create prompt' }; - } -} - -export async function listPrompts(): Promise> { - try { - const prompts = await allAsync('SELECT id, title, primary_category FROM prompts'); - return { success: true, data: prompts.data ?? [] }; - } catch (error) { - handleError(error, 'listing prompts'); - return { success: false, error: 'Failed to list prompts' }; - } -} - -export async function getPromptFiles( - promptId: string -): Promise> { - try { - const promptContentResult = await getAsync<{ content: string }>('SELECT content FROM prompts WHERE id = ?', [ - promptId - ]); - - if (!promptContentResult.success || !promptContentResult.data) { - return { success: false, error: 'Prompt not found' }; - } - - const metadataResult = await getPromptMetadata(promptId); - - if (!metadataResult.success || !metadataResult.data) { - return { success: false, error: 'Failed to get prompt metadata' }; - } - return { - success: true, - data: { - promptContent: promptContentResult.data.content, - metadata: metadataResult.data - } - }; - } catch (error) { - handleError(error, 'getting prompt files'); - return { success: false, error: 'Failed to get prompt files' }; - } -} diff --git a/src/cli/utils/prompt_display.util.ts b/src/cli/utils/prompt_display.util.ts deleted file mode 100644 index b2a8f77..0000000 --- a/src/cli/utils/prompt_display.util.ts +++ /dev/null @@ -1,76 +0,0 @@ -import chalk from 'chalk'; - -import { Prompt, Variable } from '../../shared/types'; -import { formatTitleCase, formatSnakeCase } from '../../shared/utils/string_formatter.util'; -import { FRAGMENT_PREFIX, ENV_PREFIX } from '../cli.constants'; -import { readEnvVars } from './env.util'; -import { handleError } from './error.util'; - -export async function viewPromptDetails(details: Prompt & { variables: Variable[] }, isExecute = false): Promise { - // console.clear(); - console.log(chalk.cyan('Prompt:'), details.title); - console.log(`\n${details.description || ''}`); - console.log(chalk.cyan('\nCategory:'), formatTitleCase(details.primary_category)); - - let tags: string[] = []; - - if (typeof details.tags === 'string') { - tags = details.tags.split(',').map((tag) => tag.trim()); - } else if (Array.isArray(details.tags)) { - tags = details.tags; - } - - console.log(chalk.cyan('\nTags:'), tags.length > 0 ? tags.join(', ') : 'No tags'); - console.log(chalk.cyan('\nOptions:'), '([*] Required [ ] Optional)'); - const maxNameLength = Math.max(...details.variables.map((v) => formatSnakeCase(v.name).length)); - - try { - const envVarsResult = await readEnvVars(); - const envVars = envVarsResult.success ? envVarsResult.data || [] : []; - - for (const variable of details.variables) { - const paddedName = formatSnakeCase(variable.name).padEnd(maxNameLength); - const requiredFlag = variable.optional_for_user ? '[ ]' : '[*]'; - const matchingEnvVar = envVars.find((v) => v.name === variable.name); - let status; - - if (variable.value) { - if (variable.value.startsWith(FRAGMENT_PREFIX)) { - status = chalk.blue(variable.value); - } else if (variable.value.startsWith(ENV_PREFIX)) { - const envVarName = variable.value.split(ENV_PREFIX)[1]; - const envVar = envVars.find((v: { name: string }) => v.name === envVarName); - const envValue = envVar ? envVar.value : 'Not found'; - status = chalk.magenta( - `${ENV_PREFIX}${formatSnakeCase(envVarName)} (${envValue.substring(0, 30)}${envValue.length > 30 ? '...' : ''})` - ); - } else { - status = chalk.green( - `Set: ${variable.value.substring(0, 30)}${variable.value.length > 30 ? '...' : ''}` - ); - } - } else { - status = variable.optional_for_user ? chalk.yellow('Not Set') : chalk.red('Not Set (Required)'); - } - - const hint = - !isExecute && - matchingEnvVar && - (!variable.value || (variable.value && !variable.value.startsWith(ENV_PREFIX))) - ? chalk.magenta('(Env variable available)') - : ''; - console.log(` ${chalk.green(`--${paddedName}`)} ${requiredFlag} ${hint}`); - console.log(` ${variable.role}`); - - if (!isExecute) { - console.log(` ${status}`); - } - } - - if (!isExecute) { - console.log(); - } - } catch (error) { - handleError(error, 'viewing prompt details'); - } -} diff --git a/src/cli/utils/prompts.ts b/src/cli/utils/prompts.ts new file mode 100644 index 0000000..cc9ddf4 --- /dev/null +++ b/src/cli/utils/prompts.ts @@ -0,0 +1,235 @@ +import chalk from 'chalk'; + +import { allAsync, getAsync, runAsync } from './database'; +import { handleError } from './errors'; +import { ApiResult, Fragment, PromptMetadata, Variable } from '../../shared/types'; +import { formatSnakeCase, formatTitleCase } from '../../shared/utils/string-formatter'; +import { FRAGMENT_PREFIX, ENV_PREFIX } from '../constants'; +import { readEnvVars } from './env-vars'; + +export async function createPrompt(promptMetadata: PromptMetadata, content: string): Promise> { + try { + let tagsString: string; + + if (typeof promptMetadata.tags === 'string') { + tagsString = promptMetadata.tags; + } else { + tagsString = promptMetadata.tags.join(','); + } + + const result = await runAsync( + 'INSERT INTO prompts (title, content, primary_category, directory, one_line_description, description, content_hash, tags) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', + [ + promptMetadata.title, + content, + promptMetadata.primary_category, + promptMetadata.directory, + promptMetadata.one_line_description, + promptMetadata.description, + promptMetadata.content_hash, + tagsString + ] + ); + const promptId = result.data?.lastID; + + if (!promptId) { + return { success: false, error: 'Failed to insert prompt' }; + } + + for (const subcategory of promptMetadata.subcategories) { + await runAsync('INSERT INTO subcategories (prompt_id, name) VALUES (?, ?)', [promptId, subcategory]); + } + + for (const variable of promptMetadata.variables) { + await runAsync('INSERT INTO variables (prompt_id, name, role, optional_for_user) VALUES (?, ?, ?, ?)', [ + promptId, + variable.name, + variable.role, + variable.optional_for_user + ]); + } + + for (const fragment of promptMetadata.fragments || []) { + await runAsync('INSERT INTO fragments (prompt_id, category, name) VALUES (?, ?, ?)', [ + promptId, + fragment.category, + fragment.name + ]); + } + return { success: true }; + } catch (error) { + handleError(error, 'creating prompt'); + return { success: false, error: 'Failed to create prompt' }; + } +} + +export async function listPrompts(): Promise> { + try { + const prompts = await allAsync('SELECT id, title, primary_category FROM prompts'); + + if (!prompts.success || !prompts.data) { + return { success: false, error: 'Failed to list prompts' }; + } + return { success: true, data: prompts.data }; + } catch (error) { + handleError(error, 'listing prompts'); + return { success: false, error: 'Failed to list prompts' }; + } +} + +export async function getPromptFiles( + promptId: string +): Promise> { + try { + const promptContentResult = await getAsync<{ content: string }>('SELECT content FROM prompts WHERE id = ?', [ + promptId + ]); + + if (!promptContentResult.success || !promptContentResult.data) { + return { success: false, error: 'Prompt not found' }; + } + + const metadataResult = await getPromptMetadata(promptId); + + if (!metadataResult.success || !metadataResult.data) { + return { success: false, error: 'Failed to get prompt metadata' }; + } + return { + success: true, + data: { + promptContent: promptContentResult.data.content, + metadata: metadataResult.data + } + }; + } catch (error) { + handleError(error, 'getting prompt files'); + return { success: false, error: 'Failed to get prompt files' }; + } +} + +export async function getPromptMetadata(promptId: string): Promise> { + try { + const promptResult = await getAsync('SELECT * FROM prompts WHERE id = ?', [promptId]); + + if (!promptResult.success || !promptResult.data) { + return { success: false, error: 'Metadata not found' }; + } + + const subcategoriesResult = await allAsync<{ name: string }>( + 'SELECT name FROM subcategories WHERE prompt_id = ?', + [promptId] + ); + + if (!subcategoriesResult.success || !subcategoriesResult.data) { + return { success: false, error: 'Failed to get subcategories' }; + } + + const variablesResult = await allAsync( + 'SELECT name, role, optional_for_user, value FROM variables WHERE prompt_id = ?', + [promptId] + ); + + if (!variablesResult.success || !variablesResult.data) { + return { success: false, error: 'Failed to get variables' }; + } + + const fragmentsResult = await allAsync( + 'SELECT category, name, variable FROM fragments WHERE prompt_id = ?', + [promptId] + ); + + if (!fragmentsResult.success || !fragmentsResult.data) { + return { success: false, error: 'Failed to get fragments' }; + } + + const promptMetadata: PromptMetadata = { + id: promptResult.data.id, + title: promptResult.data.title, + primary_category: promptResult.data.primary_category, + subcategories: subcategoriesResult.data.map((s) => s.name), + directory: promptResult.data.directory, + tags: promptResult.data.tags, + one_line_description: promptResult.data.one_line_description, + description: promptResult.data.description, + variables: variablesResult.data, + content_hash: promptResult.data.content_hash, + fragments: fragmentsResult.data + }; + return { success: true, data: promptMetadata }; + } catch (error) { + handleError(error, 'getting prompt metadata'); + return { success: false, error: 'Failed to get prompt metadata' }; + } +} + +export async function viewPromptDetails( + details: PromptMetadata & { variables: Variable[] }, + isExecute = false +): Promise { + console.error(details); + + console.log(chalk.cyan('Prompt:'), details.title); + console.log(`\n${details.description || ''}`); + console.log(chalk.cyan('\nCategory:'), formatTitleCase(details.primary_category)); + let tagsArray: string[]; + + if (typeof details.tags === 'string') { + tagsArray = details.tags.split(','); + } else { + tagsArray = details.tags; + } + + console.log(chalk.cyan('\nTags:'), tagsArray.length > 0 ? tagsArray.join(', ') : 'No tags'); + console.log(chalk.cyan('\nOptions:'), '([*] Required [ ] Optional)'); + const maxNameLength = Math.max(...details.variables.map((v) => formatSnakeCase(v.name).length)); + + try { + const envVarsResult = await readEnvVars(); + const envVars = envVarsResult.success ? envVarsResult.data || [] : []; + + for (const variable of details.variables) { + const paddedName = formatSnakeCase(variable.name).padEnd(maxNameLength); + const requiredFlag = variable.optional_for_user ? '[ ]' : '[*]'; + const matchingEnvVar = envVars.find((v) => v.name === variable.name); + let status; + + if (variable.value) { + if (variable.value.startsWith(FRAGMENT_PREFIX)) { + status = chalk.blue(variable.value); + } else if (variable.value.startsWith(ENV_PREFIX)) { + const envVarName = variable.value.split(ENV_PREFIX)[1]; + const envVar = envVars.find((v: { name: string }) => v.name === envVarName); + const envValue = envVar ? envVar.value : 'Not found'; + status = chalk.magenta( + `${ENV_PREFIX}${formatSnakeCase(envVarName)} (${envValue.substring(0, 30)}${envValue.length > 30 ? '...' : ''})` + ); + } else { + status = chalk.green( + `Set: ${variable.value.substring(0, 30)}${variable.value.length > 30 ? '...' : ''}` + ); + } + } else { + status = variable.optional_for_user ? chalk.yellow('Not Set') : chalk.red('Not Set (Required)'); + } + + const hint = + !isExecute && + matchingEnvVar && + (!variable.value || (variable.value && !variable.value.startsWith(ENV_PREFIX))) + ? chalk.magenta('(Env variable available)') + : ''; + console.log(` ${chalk.green(`--${paddedName}`)} ${requiredFlag} ${hint}`); + console.log(` ${variable.role}`); + + if (!isExecute) { + console.log(` ${status}`); + } + } + + if (!isExecute) { + console.log(); + } + } catch (error) { + handleError(error, 'viewing prompt details'); + } +} diff --git a/src/shared/config/__tests__/config.test.ts b/src/shared/config/__tests__/config.test.ts new file mode 100644 index 0000000..8a024c2 --- /dev/null +++ b/src/shared/config/__tests__/config.test.ts @@ -0,0 +1,213 @@ +import * as path from 'path'; + +jest.mock('../constants', () => ({ + get isCliEnvironment() { + return process.env.CLI_ENV === 'cli'; + }, + CONFIG_DIR: '/mock/config/dir', + CONFIG_FILE: '/mock/config/dir/config.json' +})); + +jest.mock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(() => '{}'), + writeFileSync: jest.fn(), + mkdirSync: jest.fn() +})); + +describe('Config', () => { + let originalEnv: NodeJS.ProcessEnv; + beforeAll(() => { + originalEnv = { ...process.env }; + }); + + beforeEach(() => { + process.env = { ...originalEnv }; + process.env.CLI_ENV = 'cli'; + + jest.resetModules(); + + jest.mock('fs', () => ({ + existsSync: jest.fn(() => false), + readFileSync: jest.fn(() => '{}'), + writeFileSync: jest.fn(), + mkdirSync: jest.fn() + })); + + jest.mock('../constants', () => ({ + get isCliEnvironment() { + return process.env.CLI_ENV === 'cli'; + }, + CONFIG_DIR: '/mock/config/dir', + CONFIG_FILE: '/mock/config/dir/config.json' + })); + + jest.mock('../common-config', () => ({ + commonConfig: { + ANTHROPIC_API_KEY: undefined, + ANTHROPIC_MODEL: 'claude-3-5-sonnet-20240620', + ANTHROPIC_MAX_TOKENS: 8000, + PROMPT_FILE_NAME: 'prompt.md', + METADATA_FILE_NAME: 'metadata.yml', + LOG_LEVEL: 'info', + REMOTE_REPOSITORY: '', + CLI_ENV: 'cli' + } + })); + + jest.mock('../../../cli/config/cli-config', () => { + const mockConfigDir = '/mock/config/dir'; + return { + cliConfig: { + PROMPTS_DIR: 'prompts', + FRAGMENTS_DIR: 'fragments', + DB_PATH: path.join(mockConfigDir, 'prompts.sqlite'), + TEMP_DIR: path.join(mockConfigDir, 'temp'), + MENU_PAGE_SIZE: 20 + } + }; + }); + }); + + afterEach(() => { + process.env = { ...originalEnv }; + }); + + describe('getConfig', () => { + it('should return default config when no file exists', () => { + process.env.CLI_ENV = 'cli'; + + const mockFs = require('fs'); + mockFs.existsSync.mockReturnValue(false); + mockFs.readFileSync.mockReturnValue('{}'); + + const { getConfig } = require('../../config'); + const config = getConfig(); + expect(config.ANTHROPIC_API_KEY).toBeUndefined(); + expect(config).toMatchObject({ + ANTHROPIC_MAX_TOKENS: 8000, + ANTHROPIC_MODEL: 'claude-3-5-sonnet-20240620', + CLI_ENV: 'cli' + }); + }); + + it('should merge file config with default config', () => { + process.env.CLI_ENV = 'cli'; + + const mockFs = require('fs'); + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + ANTHROPIC_API_KEY: 'file-key' + }) + ); + + const { getConfig } = require('../../config'); + const config = getConfig(); + expect(config.ANTHROPIC_API_KEY).toBe('file-key'); + }); + }); + + describe('getConfigValue', () => { + it('should prefer process.env value in non-CLI environment', () => { + process.env.CLI_ENV = 'app'; + process.env.ANTHROPIC_API_KEY = 'env-key'; + + const mockFs = require('fs'); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + ANTHROPIC_API_KEY: 'file-key' + }) + ); + + const { getConfigValue, clearConfigCache } = require('../../config'); + clearConfigCache(); + + const value = getConfigValue('ANTHROPIC_API_KEY'); + expect(value).toBe('env-key'); + }); + + it('should use file value in CLI environment even if env var exists', () => { + process.env.CLI_ENV = 'cli'; + process.env.ANTHROPIC_API_KEY = 'env-key'; + + const mockFs = require('fs'); + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + ANTHROPIC_API_KEY: 'file-key' + }) + ); + + const { getConfigValue, clearConfigCache } = require('../../config'); + clearConfigCache(); + + const value = getConfigValue('ANTHROPIC_API_KEY'); + expect(value).toBe('file-key'); + }); + + it('should return updated value after setConfig', () => { + process.env.CLI_ENV = 'cli'; + + const mockFs = require('fs'); + mockFs.existsSync.mockReturnValue(true); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + ANTHROPIC_API_KEY: 'initial-key' + }) + ); + + const { setConfig, getConfigValue, clearConfigCache } = require('../../config'); + clearConfigCache(); + + expect(getConfigValue('ANTHROPIC_API_KEY')).toBe('initial-key'); + + setConfig('ANTHROPIC_API_KEY', 'updated-key'); + + expect(getConfigValue('ANTHROPIC_API_KEY')).toBe('updated-key'); + expect(process.env.ANTHROPIC_API_KEY).toBe('updated-key'); + }); + }); + + describe('setConfig', () => { + it('should throw error if not in CLI environment', () => { + process.env.CLI_ENV = 'app'; + + const { setConfig } = require('../../config'); + expect(() => setConfig('ANTHROPIC_API_KEY', 'new-key')).toThrow(); + }); + + it('should update config file and process.env', () => { + process.env.CLI_ENV = 'cli'; + + const mockFs = require('fs'); + mockFs.existsSync.mockReturnValue(true); + + const { setConfig } = require('../../config'); + setConfig('ANTHROPIC_API_KEY', 'new-key'); + + expect(mockFs.writeFileSync).toHaveBeenCalledWith('/mock/config/dir/config.json', expect.any(String)); + expect(process.env.ANTHROPIC_API_KEY).toBe('new-key'); + }); + }); + + describe('environment specific behavior', () => { + it('should prefer env vars in non-CLI environment', () => { + process.env.CLI_ENV = 'app'; + process.env.ANTHROPIC_API_KEY = 'env-key'; + + const mockFs = require('fs'); + mockFs.readFileSync.mockReturnValue( + JSON.stringify({ + ANTHROPIC_API_KEY: 'file-key' + }) + ); + + const { getConfigValue, clearConfigCache } = require('../../config'); + clearConfigCache(); + + const value = getConfigValue('ANTHROPIC_API_KEY'); + expect(value).toBe('env-key'); + }); + }); +}); diff --git a/src/shared/config/common.config.ts b/src/shared/config/common-config.ts similarity index 95% rename from src/shared/config/common.config.ts rename to src/shared/config/common-config.ts index 1f868d6..531bd19 100644 --- a/src/shared/config/common.config.ts +++ b/src/shared/config/common-config.ts @@ -2,7 +2,6 @@ import dotenv from 'dotenv'; import { Config } from '.'; -// Load .env file if running locally if (process.env.NODE_ENV !== 'production') { dotenv.config(); } diff --git a/src/shared/config/config.constants.ts b/src/shared/config/constants.ts similarity index 69% rename from src/shared/config/config.constants.ts rename to src/shared/config/constants.ts index 1313548..d754ffa 100644 --- a/src/shared/config/config.constants.ts +++ b/src/shared/config/constants.ts @@ -1,9 +1,7 @@ import * as os from 'os'; import * as path from 'path'; -import { commonConfig } from './common.config'; - -export const isCliEnvironment = commonConfig.CLI_ENV === 'cli'; +export const isCliEnvironment = process.env.CLI_ENV === 'cli'; export const CONFIG_DIR = isCliEnvironment ? path.join(os.homedir(), '.prompt-library-cli') diff --git a/src/shared/config/index.ts b/src/shared/config/index.ts index 516bcd1..65dd5dd 100644 --- a/src/shared/config/index.ts +++ b/src/shared/config/index.ts @@ -1,78 +1,86 @@ import * as fs from 'fs'; -import { CommonConfig, commonConfig } from './common.config'; -import { CONFIG_DIR, CONFIG_FILE, isCliEnvironment } from './config.constants'; -import { AppConfig, appConfig } from '../../app/config/app.config'; -import { CliConfig, cliConfig } from '../../cli/cli.config'; +import { CommonConfig, commonConfig } from './common-config'; +import { CONFIG_DIR, CONFIG_FILE, isCliEnvironment } from './constants'; +import { appConfig, AppConfig } from '../../app/config/app-config'; +import { CliConfig, cliConfig } from '../../cli/config/cli-config'; export type Config = CommonConfig & (CliConfig | AppConfig); let loadedConfig: Config | null = null; +let lastCliEnv: string | undefined; + +export function clearConfigCache(): void { + loadedConfig = null; + lastCliEnv = undefined; +} function loadConfig(): Config { - const environmentConfig = isCliEnvironment ? cliConfig : appConfig; - let config: Config = { ...commonConfig, ...environmentConfig }; + if (process.env.CLI_ENV !== lastCliEnv) { + loadedConfig = null; + lastCliEnv = process.env.CLI_ENV; + } - if (isCliEnvironment) { - if (!fs.existsSync(CONFIG_DIR)) { - fs.mkdirSync(CONFIG_DIR, { recursive: true }); - } + if (loadedConfig) { + return JSON.parse(JSON.stringify(loadedConfig)); + } - if (fs.existsSync(CONFIG_FILE)) { + const environmentConfig = isCliEnvironment ? cliConfig : appConfig; + let config = { + ...commonConfig, + ...environmentConfig + } as Config; + + if (isCliEnvironment && fs.existsSync(CONFIG_FILE)) { + try { const fileConfig = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf-8')); config = { ...config, ...fileConfig }; + } catch (error) { + console.error('Error loading config file:', error); } } + + loadedConfig = JSON.parse(JSON.stringify(config)); return config; } +export function getConfig(): Readonly { + return loadConfig(); +} + export function setConfig(key: K, value: Config[K]): void { - if (!isCliEnvironment) { + if (process.env.CLI_ENV !== 'cli') { throw new Error('setConfig is only available in CLI environment'); } - if (!loadedConfig) { - loadedConfig = loadConfig(); + if (!fs.existsSync(CONFIG_DIR)) { + fs.mkdirSync(CONFIG_DIR, { recursive: true }); } - loadedConfig[key] = value; + const config = loadConfig(); + const updatedConfig = { ...config, [key]: value }; + fs.writeFileSync(CONFIG_FILE, JSON.stringify(updatedConfig, null, 2)); - // Update process.env to reflect the change - if (typeof value === 'string') { - process.env[key.toString()] = value; - } - - // Write to file - fs.writeFileSync(CONFIG_FILE, JSON.stringify(loadedConfig, null, 2)); - - // Clear the cache by reassigning loadedConfig to null - loadedConfig = null as unknown as Config; -} + loadedConfig = updatedConfig; -export function getConfig(): Readonly { - if (loadedConfig === null) { - loadedConfig = loadConfig(); + if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + process.env[key] = String(value); } - return loadedConfig; } export function getConfigValue(key: K): Config[K] { - if (loadedConfig === null) { - loadedConfig = loadConfig(); - } + const config = loadConfig(); - if (isCliEnvironment) { - return loadedConfig[key]; - } else { - const envValue = process.env[key.toString()]; - return (envValue !== undefined ? envValue : loadedConfig[key]) as Config[K]; - } -} - -type ConfigValue = Config[keyof Config]; + if (!isCliEnvironment && process.env[key] !== undefined) { + const envValue = process.env[key]; + const currentValue = config[key]; -export const config: Readonly = new Proxy({} as Config, { - get(_, prop: string): ConfigValue { - return getConfigValue(prop as keyof Config); + if (typeof currentValue === 'number') { + return Number(envValue) as Config[K]; + } else if (typeof currentValue === 'boolean') { + return (envValue?.toLowerCase() === 'true') as unknown as Config[K]; + } + return envValue as Config[K]; } -}); + return config[key]; +} diff --git a/src/shared/types/index.ts b/src/shared/types/index.ts index bdee51e..58ed1bd 100644 --- a/src/shared/types/index.ts +++ b/src/shared/types/index.ts @@ -6,15 +6,6 @@ export interface EnvVar { prompt_id?: number; } -export interface Prompt { - id: string; - title: string; - primary_category: string; - description?: string; - tags?: string | string[]; - variables: Variable[]; -} - export interface CategoryItem { id: string; title: string; @@ -37,12 +28,13 @@ export type ApiResult = { error?: string; }; -export interface Metadata { +export interface PromptMetadata { + id?: string; title: string; primary_category: string; subcategories: string[]; directory: string; - tags: string[]; + tags: string | string[]; one_line_description: string; description: string; variables: Variable[]; diff --git a/src/shared/utils/__tests__/anthropic-client.test.ts b/src/shared/utils/__tests__/anthropic-client.test.ts new file mode 100644 index 0000000..ab21e0e --- /dev/null +++ b/src/shared/utils/__tests__/anthropic-client.test.ts @@ -0,0 +1,175 @@ +import { Anthropic } from '@anthropic-ai/sdk'; +import { Message, MessageStreamEvent } from '@anthropic-ai/sdk/resources/messages.js'; + +import { commonConfig } from '../../config/common-config'; +import { sendAnthropicRequestClassic, sendAnthropicRequestStream, validateAnthropicApiKey } from '../anthropic-client'; + +jest.mock('@anthropic-ai/sdk'); +jest.mock('../../../cli/utils/errors'); +jest.mock('../../config/common-config'); + +describe('AnthropicClientUtils', () => { + const testMessages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there' } + ]; + const mockResponse = { + id: 'msg_123', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Test response' }], + model: 'claude-3', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 20 } + } as Message; + beforeEach(() => { + jest.clearAllMocks(); + process.env.ANTHROPIC_API_KEY = 'test-key'; + }); + + afterEach(() => { + delete process.env.ANTHROPIC_API_KEY; + }); + + describe('sendAnthropicRequestClassic', () => { + it('should successfully send a classic request', async () => { + const mockCreate = jest.fn().mockResolvedValue(mockResponse); + const mockMessagesAPI = { + create: mockCreate, + stream: jest.fn() + } as unknown as typeof Anthropic.prototype.messages; + (Anthropic as jest.MockedClass).prototype.messages = mockMessagesAPI; + + const result = await sendAnthropicRequestClassic(testMessages); + expect(result).toBe(mockResponse); + expect(mockCreate).toHaveBeenCalledWith({ + model: commonConfig.ANTHROPIC_MODEL, + max_tokens: commonConfig.ANTHROPIC_MAX_TOKENS, + messages: testMessages.map((msg) => ({ + role: msg.role === 'user' ? 'user' : 'assistant', + content: msg.content + })) + }); + }); + + it('should handle API errors properly', async () => { + const mockError = new Error('API Error'); + const mockCreate = jest.fn().mockRejectedValue(mockError); + (Anthropic as jest.MockedClass).prototype.messages = { + create: mockCreate, + stream: jest.fn(), + _client: {} as any + } as any; + + await expect(sendAnthropicRequestClassic(testMessages)).rejects.toThrow(mockError); + }); + }); + + describe('sendAnthropicRequestStream', () => { + it('should successfully stream responses', async () => { + const mockStreamEvents: MessageStreamEvent[] = [ + { + type: 'message_start', + message: { + id: 'msg_123', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-3', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 } + } + }, + { type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }, + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Test' } }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { + stop_reason: 'end_turn', + stop_sequence: null + }, + usage: { output_tokens: 1 } + }, + { type: 'message_stop' } + ]; + const mockStream = { + [Symbol.asyncIterator]: async function* () { + for (const event of mockStreamEvents) { + yield event; + } + } + }; + const mockStreamFn = jest.fn().mockReturnValue(mockStream); + const mockMessagesAPI = { + stream: mockStreamFn, + create: jest.fn() + } as unknown as typeof Anthropic.prototype.messages; + (Anthropic as jest.MockedClass).prototype.messages = mockMessagesAPI; + + const events: MessageStreamEvent[] = []; + + for await (const event of sendAnthropicRequestStream(testMessages)) { + events.push(event); + } + + expect(events).toEqual(mockStreamEvents); + expect(mockStreamFn).toHaveBeenCalledWith({ + model: commonConfig.ANTHROPIC_MODEL, + max_tokens: commonConfig.ANTHROPIC_MAX_TOKENS, + messages: testMessages.map((msg) => ({ + role: msg.role === 'user' ? 'user' : 'assistant', + content: msg.content + })) + }); + }); + + it('should handle streaming errors properly', async () => { + const mockError = new Error('Stream Error'); + const mockStreamFn = jest.fn().mockImplementation(() => { + throw mockError; + }); + (Anthropic as jest.MockedClass).prototype.messages = { + stream: mockStreamFn, + create: jest.fn(), + _client: {} as any + } as any; + + const generator = sendAnthropicRequestStream(testMessages); + await expect(generator.next()).rejects.toThrow(mockError); + }); + }); + + describe('validateAnthropicApiKey', () => { + it('should return true for valid API key', async () => { + const mockCreate = jest.fn().mockResolvedValue(mockResponse); + (Anthropic as jest.MockedClass).prototype.messages = { + create: mockCreate, + stream: jest.fn(), + _client: {} as any + } as any; + + const result = await validateAnthropicApiKey(); + expect(result).toBe(true); + expect(mockCreate).toHaveBeenCalledWith({ + model: commonConfig.ANTHROPIC_MODEL, + max_tokens: commonConfig.ANTHROPIC_MAX_TOKENS, + messages: [{ role: 'user', content: 'Test request' }] + }); + }); + + it('should return false for invalid API key', async () => { + const mockCreate = jest.fn().mockRejectedValue(new Error('Invalid API key')); + (Anthropic as jest.MockedClass).prototype.messages = { + create: mockCreate, + stream: jest.fn(), + _client: {} as any + } as any; + + const result = await validateAnthropicApiKey(); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/shared/utils/__tests__/file-system.test.ts b/src/shared/utils/__tests__/file-system.test.ts new file mode 100644 index 0000000..8fa1b4a --- /dev/null +++ b/src/shared/utils/__tests__/file-system.test.ts @@ -0,0 +1,224 @@ +import { Dirent, Stats } from 'fs'; +import * as fs from 'fs/promises'; + +import { jest } from '@jest/globals'; + +import { handleError } from '../../../cli/utils/errors'; +import { + readFileContent, + writeFileContent, + readDirectory, + createDirectory, + renameFile, + copyFile, + removeDirectory, + fileExists, + isDirectory, + isFile +} from '../file-system'; + +jest.mock('fs/promises'); +const mockFs = jest.mocked(fs); +jest.mock('../../../cli/utils/errors'); +const mockHandleError = jest.mocked(handleError); +describe('FileSystemUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('readFileContent', () => { + it('should read file content successfully', async () => { + const testContent = 'test content'; + mockFs.readFile.mockResolvedValue(testContent); + + const result = await readFileContent('test.txt'); + expect(result).toBe(testContent); + expect(mockFs.readFile).toHaveBeenCalledWith('test.txt', 'utf-8'); + }); + + it('should handle errors when reading file', async () => { + const error = new Error('Read error'); + mockFs.readFile.mockRejectedValue(error); + + await expect(readFileContent('test.txt')).rejects.toThrow(error); + expect(mockHandleError).toHaveBeenCalledWith(error, 'reading file test.txt'); + }); + }); + + describe('writeFileContent', () => { + it('should write file content successfully', async () => { + const testContent = 'test content'; + mockFs.writeFile.mockResolvedValue(); + + await writeFileContent('test.txt', testContent); + + expect(mockFs.writeFile).toHaveBeenCalledWith('test.txt', testContent, 'utf-8'); + }); + + it('should handle errors when writing file', async () => { + const error = new Error('Write error'); + mockFs.writeFile.mockRejectedValue(error); + + await expect(writeFileContent('test.txt', 'content')).rejects.toThrow(error); + expect(mockHandleError).toHaveBeenCalledWith(error, 'writing file test.txt'); + }); + }); + + describe('readDirectory', () => { + it('should read directory contents successfully', async () => { + const testFiles = ['file1.txt', 'file2.txt']; + (mockFs.readdir as jest.MockedFunction).mockResolvedValue( + testFiles as unknown as Dirent[] + ); + + const result = await readDirectory('testDir'); + expect(result).toEqual(testFiles); + expect(mockFs.readdir).toHaveBeenCalledWith('testDir', { withFileTypes: false }); + }); + + it('should handle errors when reading directory', async () => { + const error = new Error('Read dir error'); + mockFs.readdir.mockRejectedValue(error); + + await expect(readDirectory('testDir')).rejects.toThrow(error); + expect(mockHandleError).toHaveBeenCalledWith(error, 'reading directory testDir'); + }); + }); + + describe('createDirectory', () => { + it('should create directory successfully', async () => { + mockFs.mkdir.mockResolvedValue(undefined); + + await createDirectory('testDir'); + + expect(mockFs.mkdir).toHaveBeenCalledWith('testDir', { recursive: true }); + }); + + it('should handle errors when creating directory', async () => { + const error = new Error('Create dir error'); + mockFs.mkdir.mockRejectedValue(error); + + await expect(createDirectory('testDir')).rejects.toThrow(error); + expect(mockHandleError).toHaveBeenCalledWith(error, 'creating directory testDir'); + }); + }); + + describe('renameFile', () => { + it('should rename file successfully', async () => { + mockFs.rename.mockResolvedValue(); + + await renameFile('old.txt', 'new.txt'); + + expect(mockFs.rename).toHaveBeenCalledWith('old.txt', 'new.txt'); + }); + + it('should handle errors when renaming file', async () => { + const error = new Error('Rename error'); + mockFs.rename.mockRejectedValue(error); + + await expect(renameFile('old.txt', 'new.txt')).rejects.toThrow(error); + expect(mockHandleError).toHaveBeenCalledWith(error, 'renaming file from old.txt to new.txt'); + }); + }); + + describe('copyFile', () => { + it('should copy file successfully', async () => { + mockFs.copyFile.mockResolvedValue(); + + await copyFile('src.txt', 'dst.txt'); + + expect(mockFs.copyFile).toHaveBeenCalledWith('src.txt', 'dst.txt'); + }); + + it('should handle errors when copying file', async () => { + const error = new Error('Copy error'); + mockFs.copyFile.mockRejectedValue(error); + + await expect(copyFile('src.txt', 'dst.txt')).rejects.toThrow(error); + expect(mockHandleError).toHaveBeenCalledWith(error, 'copying file from src.txt to dst.txt'); + }); + }); + + describe('removeDirectory', () => { + it('should remove directory successfully', async () => { + mockFs.rm.mockResolvedValue(); + + await removeDirectory('testDir'); + + expect(mockFs.rm).toHaveBeenCalledWith('testDir', { recursive: true, force: true }); + }); + + it('should handle errors when removing directory', async () => { + const error = new Error('Remove dir error'); + mockFs.rm.mockRejectedValue(error); + + await expect(removeDirectory('testDir')).rejects.toThrow(error); + expect(mockHandleError).toHaveBeenCalledWith(error, 'removing directory testDir'); + }); + }); + + describe('fileExists', () => { + it('should return true when file exists', async () => { + mockFs.access.mockResolvedValue(); + + const result = await fileExists('test.txt'); + expect(result).toBe(true); + expect(mockFs.access).toHaveBeenCalledWith('test.txt'); + }); + + it('should return false when file does not exist', async () => { + mockFs.access.mockRejectedValue(new Error('File not found')); + + const result = await fileExists('test.txt'); + expect(result).toBe(false); + }); + }); + + describe('isDirectory', () => { + it('should return true for directories', async () => { + mockFs.stat.mockResolvedValue({ isDirectory: () => true } as Stats); + + const result = await isDirectory('testDir'); + expect(result).toBe(true); + expect(mockFs.stat).toHaveBeenCalledWith('testDir'); + }); + + it('should return false for non-directories', async () => { + mockFs.stat.mockResolvedValue({ isDirectory: () => false } as Stats); + + const result = await isDirectory('test.txt'); + expect(result).toBe(false); + }); + + it('should return false when path does not exist', async () => { + mockFs.stat.mockRejectedValue(new Error('Path not found')); + + const result = await isDirectory('nonexistent'); + expect(result).toBe(false); + }); + }); + + describe('isFile', () => { + it('should return true for files', async () => { + mockFs.stat.mockResolvedValue({ isFile: () => true } as Stats); + + const result = await isFile('test.txt'); + expect(result).toBe(true); + expect(mockFs.stat).toHaveBeenCalledWith('test.txt'); + }); + + it('should return false for non-files', async () => { + mockFs.stat.mockResolvedValue({ isFile: () => false } as Stats); + + const result = await isFile('testDir'); + expect(result).toBe(false); + }); + + it('should return false when path does not exist', async () => { + mockFs.stat.mockRejectedValue(new Error('Path not found')); + + const result = await isFile('nonexistent'); + expect(result).toBe(false); + }); + }); +}); diff --git a/src/shared/utils/__tests__/logger.test.ts b/src/shared/utils/__tests__/logger.test.ts new file mode 100644 index 0000000..0dec515 --- /dev/null +++ b/src/shared/utils/__tests__/logger.test.ts @@ -0,0 +1,93 @@ +import logger from '../logger'; + +jest.mock('../../config/common-config', () => ({ + commonConfig: { + LOG_LEVEL: 'debug' + } +})); + +describe('LoggerUtils', () => { + let consoleSpy: jest.SpyInstance; + const originalEnv = process.env; + beforeEach(() => { + process.env = { ...originalEnv }; + delete process.env.LOG_LEVEL; + + logger.setLogLevel('debug'); + + consoleSpy = jest.spyOn(console, 'log').mockImplementation(); + }); + + afterEach(() => { + consoleSpy.mockRestore(); + process.env = originalEnv; + }); + + describe('log levels', () => { + it('should log at info level', () => { + logger.setLogLevel('info'); + logger.info('test message'); + expect(consoleSpy).toHaveBeenCalled(); + const logMessage = consoleSpy.mock.calls[0][0]; + expect(logMessage).toContain('[INFO]'); + }); + + it('should log at error level', () => { + logger.setLogLevel('error'); + logger.error('test error'); + expect(consoleSpy).toHaveBeenCalled(); + const logMessage = consoleSpy.mock.calls[0][0]; + expect(logMessage).toContain('[ERROR]'); + }); + + it('should log at warn level', () => { + logger.setLogLevel('warn'); + logger.warn('test warning'); + expect(consoleSpy).toHaveBeenCalled(); + const logMessage = consoleSpy.mock.calls[0][0]; + expect(logMessage).toContain('[WARN]'); + }); + + it('should log at debug level', () => { + logger.setLogLevel('debug'); + logger.debug('test debug'); + expect(consoleSpy).toHaveBeenCalled(); + const logMessage = consoleSpy.mock.calls[0][0]; + expect(logMessage).toContain('[DEBUG]'); + }); + }); + + describe('setLogLevel', () => { + it('should respect log level settings', () => { + logger.setLogLevel('error'); + + logger.debug('test debug'); + expect(consoleSpy).not.toHaveBeenCalled(); + + logger.info('test info'); + expect(consoleSpy).not.toHaveBeenCalled(); + + logger.warn('test warn'); + expect(consoleSpy).not.toHaveBeenCalled(); + + logger.error('test error'); + expect(consoleSpy).toHaveBeenCalledTimes(1); + expect(consoleSpy.mock.calls[0][0]).toContain('[ERROR]'); + }); + + it('should allow all logs at debug level', () => { + logger.setLogLevel('debug'); + + logger.debug('test debug'); + logger.info('test info'); + logger.warn('test warn'); + logger.error('test error'); + + expect(consoleSpy).toHaveBeenCalledTimes(4); + expect(consoleSpy.mock.calls[0][0]).toContain('[DEBUG]'); + expect(consoleSpy.mock.calls[1][0]).toContain('[INFO]'); + expect(consoleSpy.mock.calls[2][0]).toContain('[WARN]'); + expect(consoleSpy.mock.calls[3][0]).toContain('[ERROR]'); + }); + }); +}); diff --git a/src/shared/utils/__tests__/prompt-processing.test.ts b/src/shared/utils/__tests__/prompt-processing.test.ts new file mode 100644 index 0000000..630a313 --- /dev/null +++ b/src/shared/utils/__tests__/prompt-processing.test.ts @@ -0,0 +1,252 @@ +import { Message, RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages.js'; +import { jest } from '@jest/globals'; + +import { sendAnthropicRequestClassic, sendAnthropicRequestStream } from '../anthropic-client'; +import { updatePromptWithVariables, processPromptContent } from '../prompt-processing'; + +jest.mock('../anthropic-client'); +const mockSendAnthropicRequestClassic = sendAnthropicRequestClassic as jest.MockedFunction< + typeof sendAnthropicRequestClassic +>; +const mockSendAnthropicRequestStream = sendAnthropicRequestStream as jest.MockedFunction< + typeof sendAnthropicRequestStream +>; +describe('PromptProcessingUtils', () => { + beforeEach(() => { + jest.clearAllMocks(); + + jest.spyOn(console, 'log').mockImplementation(() => {}); + jest.spyOn(console, 'error').mockImplementation(() => {}); + jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + describe('updatePromptWithVariables', () => { + it('should replace variables in content with provided values', () => { + const content = 'Hello {{name}}, welcome to {{place}}!'; + const variables = { + name: 'John', + place: 'Paris' + }; + const result = updatePromptWithVariables(content, variables); + expect(result).toBe('Hello John, welcome to Paris!'); + }); + + it('should handle multiple occurrences of the same variable', () => { + const content = '{{name}} is {{name}}'; + const variables = { name: 'John' }; + const result = updatePromptWithVariables(content, variables); + expect(result).toBe('John is John'); + }); + + it('should handle empty variables object', () => { + const content = 'Hello {{name}}!'; + const variables = {}; + const result = updatePromptWithVariables(content, variables); + expect(result).toBe('Hello {{name}}!'); + }); + + it('should throw error for null or undefined content', () => { + expect(() => { + updatePromptWithVariables(null as unknown as string, {}); + }).toThrow('Content cannot be null or undefined'); + }); + + it('should throw error if variable value is not a string', () => { + const content = 'Hello {{name}}!'; + const variables = { name: 123 as any }; + expect(() => { + updatePromptWithVariables(content, variables); + }).toThrow('Variable value for key "name" must be a string'); + }); + }); + + describe('processPromptContent', () => { + const mockMessages = [ + { role: 'user', content: 'Hello' }, + { role: 'assistant', content: 'Hi there!' } + ]; + it('should process classic response correctly', async () => { + const mockMessage: Message = { + id: 'msg_123', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Test response' }], + model: 'claude-2', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 20 } + }; + mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage); + + const result = await processPromptContent(mockMessages, false, false); + expect(result).toBe('Test response'); + expect(mockSendAnthropicRequestClassic).toHaveBeenCalledWith(mockMessages); + }); + + it('should process streaming response correctly', async () => { + const mockStream = [ + { type: 'content_block_delta', index: 0, delta: { type: 'text_delta', text: 'Hello' } as const }, + { type: 'content_block_delta', index: 1, delta: { type: 'text_delta', text: ' world' } as const } + ]; + mockSendAnthropicRequestStream.mockImplementation(async function* () { + for (const event of mockStream) { + yield event as RawMessageStreamEvent; + } + }); + + const result = await processPromptContent(mockMessages, true, false); + expect(result).toBe('Hello world'); + expect(mockSendAnthropicRequestStream).toHaveBeenCalledWith(mockMessages); + }); + + it('should handle complex message content', async () => { + const mockMessage: Message = { + id: 'msg_123', + type: 'message', + role: 'assistant', + content: [ + { type: 'text', text: 'Text content' }, + { + type: 'tool_use', + id: 'tool_123', + name: 'calculator', + input: { expression: '2+2' } + } + ], + model: 'claude-2', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 10, output_tokens: 20 } + }; + mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage); + + const result = await processPromptContent(mockMessages, false, false); + expect(result).toContain('Text content'); + expect(result).toContain('[Tool Use: calculator]'); + expect(result).toContain('Input: {"expression":"2+2"}'); + }); + + it('should handle unknown block types in message content', async () => { + const mockMessage: Message = { + id: 'msg_456', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Known text' }, { type: 'unknown_type', data: 'Some data' } as any], + model: 'claude-2', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 15, output_tokens: 25 } + }; + mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage); + + const result = await processPromptContent(mockMessages, false, false); + expect(result).toContain('Known text'); + expect(result).toContain(JSON.stringify({ type: 'unknown_type', data: 'Some data' })); + }); + + it('should handle invalid blocks in message content', async () => { + const mockMessage: Message = { + id: 'msg_789', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Valid text' }, null as any, undefined as any], + model: 'claude-2', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 20, output_tokens: 30 } + }; + mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage); + + const result = await processPromptContent(mockMessages, false, false); + expect(result).toBe('Valid text'); + }); + + it('should handle errors in classic mode', async () => { + mockSendAnthropicRequestClassic.mockRejectedValue(new Error('API Error')); + + await expect(processPromptContent(mockMessages, false, false)).rejects.toThrow('API Error'); + }); + + it('should handle errors in streaming mode', async () => { + mockSendAnthropicRequestStream.mockImplementation(async function* () { + throw new Error('Stream Error'); + }); + + await expect(processPromptContent(mockMessages, true, false)).rejects.toThrow('Stream Error'); + }); + + it('should throw error if messages array is empty', async () => { + await expect(processPromptContent([], false, false)).rejects.toThrow('Messages must be a non-empty array'); + }); + + it('should log messages when logging is true', async () => { + const mockMessage: Message = { + id: 'msg_101', + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: 'Logged response' }], + model: 'claude-2', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 5, output_tokens: 15 } + }; + mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage); + + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + await processPromptContent(mockMessages, false, true); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('You:')); + expect(consoleLogSpy).toHaveBeenCalledWith('Hi there!'); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('AI:')); + + consoleLogSpy.mockRestore(); + }); + + it('should return empty string if message content is empty', async () => { + const mockMessage: Message = { + id: 'msg_202', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-2', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 0, output_tokens: 0 } + }; + mockSendAnthropicRequestClassic.mockResolvedValue(mockMessage); + + const result = await processPromptContent(mockMessages, false, false); + expect(result).toBe(''); + }); + + it('should process streaming response with partial_json correctly', async () => { + const mockStream = [ + { + type: 'content_block_delta', + index: 0, + delta: { type: 'partial_json', partial_json: '{"key":' } as const + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'partial_json', partial_json: '"value"}' } as const + } + ]; + mockSendAnthropicRequestStream.mockImplementation(async function* () { + for (const event of mockStream) { + yield event as RawMessageStreamEvent; + } + }); + + const processStdoutWriteSpy = jest.spyOn(process.stdout, 'write').mockImplementation(() => true); + const result = await processPromptContent(mockMessages, true, false); + expect(result).toBe('{"key":"value"}'); + expect(mockSendAnthropicRequestStream).toHaveBeenCalledWith(mockMessages); + + expect(processStdoutWriteSpy).toHaveBeenCalledWith('{"key":'); + expect(processStdoutWriteSpy).toHaveBeenCalledWith('"value"}'); + + processStdoutWriteSpy.mockRestore(); + }); + }); +}); diff --git a/src/shared/utils/__tests__/string-formatter.test.ts b/src/shared/utils/__tests__/string-formatter.test.ts new file mode 100644 index 0000000..e530ecd --- /dev/null +++ b/src/shared/utils/__tests__/string-formatter.test.ts @@ -0,0 +1,23 @@ +import { formatTitleCase, formatSnakeCase } from '../string-formatter'; + +describe('StringFormatterUtils', () => { + describe('formatTitleCase', () => { + it('should format string to title case', () => { + expect(formatTitleCase('hello_world')).toBe('Hello World'); + expect(formatTitleCase('test-case')).toBe('Test Case'); + }); + }); + + describe('formatSnakeCase', () => { + it('should format string to snake case', () => { + expect(formatSnakeCase('Hello World')).toBe('hello_world'); + expect(formatSnakeCase('TestCase')).toBe('test_case'); + expect(formatSnakeCase('{TEST_VAR}')).toBe('test_var'); + }); + + it('should handle special characters', () => { + expect(formatSnakeCase('Hello {World}')).toBe('hello_world'); + expect(formatSnakeCase('TEST_VAR')).toBe('test_var'); + }); + }); +}); diff --git a/src/shared/utils/anthropic_client.util.ts b/src/shared/utils/anthropic-client.ts similarity index 94% rename from src/shared/utils/anthropic_client.util.ts rename to src/shared/utils/anthropic-client.ts index 5bfab25..bdcdf36 100644 --- a/src/shared/utils/anthropic_client.util.ts +++ b/src/shared/utils/anthropic-client.ts @@ -1,9 +1,9 @@ import { Anthropic } from '@anthropic-ai/sdk'; import { Message, MessageStreamEvent } from '@anthropic-ai/sdk/resources'; -import { AppError, handleError } from '../../cli/utils/error.util'; +import { AppError, handleError } from '../../cli/utils/errors'; import { getConfigValue } from '../config'; -import { commonConfig } from '../config/common.config'; +import { commonConfig } from '../config/common-config'; let anthropicClient: Anthropic | null = null; diff --git a/src/shared/utils/file_system.util.ts b/src/shared/utils/file-system.ts similarity index 93% rename from src/shared/utils/file_system.util.ts rename to src/shared/utils/file-system.ts index ba6ea09..755aeac 100644 --- a/src/shared/utils/file_system.util.ts +++ b/src/shared/utils/file-system.ts @@ -1,6 +1,6 @@ import * as fs from 'fs/promises'; -import { handleError } from '../../cli/utils/error.util'; +import { handleError } from '../../cli/utils/errors'; export async function readFileContent(filePath: string): Promise { try { @@ -22,7 +22,8 @@ export async function writeFileContent(filePath: string, content: string): Promi export async function readDirectory(dirPath: string): Promise { try { - return await fs.readdir(dirPath); + const entries = await fs.readdir(dirPath, { withFileTypes: false }); + return entries; } catch (error) { handleError(error, `reading directory ${dirPath}`); throw error; diff --git a/src/shared/utils/logger.util.ts b/src/shared/utils/logger.ts similarity index 86% rename from src/shared/utils/logger.util.ts rename to src/shared/utils/logger.ts index 85e2a90..5a1885b 100644 --- a/src/shared/utils/logger.util.ts +++ b/src/shared/utils/logger.ts @@ -1,6 +1,6 @@ import chalk from 'chalk'; -import { commonConfig } from '../config/common.config'; +import { commonConfig } from '../config/common-config'; enum LogLevel { DEBUG, @@ -33,8 +33,9 @@ function normalizeLogLevel(level: LogLevelKey): keyof typeof LogLevel { class ConfigurableLogger implements Logger { private currentLogLevel: LogLevel; - constructor(initialLogLevel: LogLevelKey = commonConfig.LOG_LEVEL) { - this.currentLogLevel = LogLevel[normalizeLogLevel(initialLogLevel)]; + constructor(initialLogLevel?: LogLevelKey) { + const logLevel = initialLogLevel || (process.env.LOG_LEVEL as LogLevelKey) || commonConfig.LOG_LEVEL; + this.currentLogLevel = LogLevel[normalizeLogLevel(logLevel)]; } setLogLevel(level: LogLevelKey): void { diff --git a/src/shared/utils/prompt_processing.util.ts b/src/shared/utils/prompt-processing.ts similarity index 71% rename from src/shared/utils/prompt_processing.util.ts rename to src/shared/utils/prompt-processing.ts index 227b360..b93ab2d 100644 --- a/src/shared/utils/prompt_processing.util.ts +++ b/src/shared/utils/prompt-processing.ts @@ -1,13 +1,21 @@ import { Message } from '@anthropic-ai/sdk/resources'; import chalk from 'chalk'; -import { sendAnthropicRequestClassic, sendAnthropicRequestStream } from './anthropic_client.util'; -import { handleError } from '../../cli/utils/error.util'; +import { sendAnthropicRequestClassic, sendAnthropicRequestStream } from './anthropic-client'; +import { handleError } from '../../cli/utils/errors'; export function updatePromptWithVariables(content: string, variables: Record): string { + if (content === null || content === undefined) { + throw new Error('Content cannot be null or undefined'); + } + try { return Object.entries(variables).reduce((updatedContent, [key, value]) => { - const regex = new RegExp(`{{${key.replace(/{{|}}/g, '')}}}`, 'g'); + if (typeof value !== 'string') { + throw new Error(`Variable value for key "${key}" must be a string`); + } + + const regex = new RegExp(`{{${key.replace(/[{}]/g, '')}}}`, 'g'); return updatedContent.replace(regex, value); }, content); } catch (error) { @@ -17,18 +25,23 @@ export function updatePromptWithVariables(content: string, variables: Record { + if (!block || typeof block !== 'object') { + return ''; + } + if (block.type === 'text') { - return block.text; + return block.text || ''; } else if (block.type === 'tool_use') { return `[Tool Use: ${block.name}]\nInput: ${JSON.stringify(block.input)}`; } return JSON.stringify(block); }) + .filter(Boolean) .join('\n'); } @@ -37,6 +50,10 @@ export async function processPromptContent( useStreaming: boolean = false, logging: boolean = true ): Promise { + if (!Array.isArray(messages) || messages.length === 0) { + throw new Error('Messages must be a non-empty array'); + } + try { if (logging) { console.log(chalk.blue(chalk.bold('\nYou:'))); @@ -57,11 +74,15 @@ export async function processPromptContent( } async function processStreamingResponse(messages: { role: string; content: string }[]): Promise { + if (!Array.isArray(messages)) { + throw new Error('Messages must be an array'); + } + let fullResponse = ''; try { for await (const event of sendAnthropicRequestStream(messages)) { - if (event.type === 'content_block_delta' && event.delta) { + if (event?.type === 'content_block_delta' && event.delta) { if ('text' in event.delta) { fullResponse += event.delta.text; process.stdout.write(event.delta.text); diff --git a/src/shared/utils/string_formatter.util.ts b/src/shared/utils/string-formatter.ts similarity index 62% rename from src/shared/utils/string_formatter.util.ts rename to src/shared/utils/string-formatter.ts index 19bfd42..dc2fded 100644 --- a/src/shared/utils/string_formatter.util.ts +++ b/src/shared/utils/string-formatter.ts @@ -1,6 +1,6 @@ export function formatTitleCase(category: string): string { return category - .split('_') + .split(/[_-]/) .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) .join(' '); } @@ -8,9 +8,9 @@ export function formatTitleCase(category: string): string { export function formatSnakeCase(variableName: string): string { return variableName .replace(/[{}]/g, '') + .replace(/([a-z])([A-Z])/g, '$1_$2') .toLowerCase() - .replace(/_/g, ' ') - .replace(/\w\S*/g, (word) => word.charAt(0).toUpperCase() + word.substr(1).toLowerCase()) - .replace(/ /g, '_') - .toLowerCase(); + .replace(/[-\s_]+/g, '_') + .replace(/^_/, '') + .replace(/_$/g, ''); } diff --git a/tests/.gitkeep b/tests/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tsconfig.json b/tsconfig.json index 83556d5..e2c7e75 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "typeRoots": ["src/shared/types", "./node_modules/@types"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist", "tests"] + "exclude": ["node_modules", "dist", "**/__tests__/**"] } \ No newline at end of file diff --git a/tsconfig.test.json b/tsconfig.test.json index d2f7a15..efe911b 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -2,7 +2,10 @@ "extends": "./tsconfig.json", "compilerOptions": { "module": "commonjs", - "types": ["jest", "node"] + "types": ["jest", "node"], + "rootDir": ".", + "noEmit": true }, - "include": ["src/**/*", "tests/**/*"] + "include": ["src/**/*", "src/**/__tests__/**/*"], + "exclude": ["node_modules", "dist"] } \ No newline at end of file