├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── jest.config.ts ├── package-lock.json ├── package.json ├── src ├── component.ts ├── index.ts ├── logger.ts ├── persona.ts ├── server.ts └── service.ts ├── test ├── component.test.ts ├── logger.test.ts ├── persona.test.ts ├── server.test.ts └── service.test.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependencies 2 | node_modules/ 3 | jspm_packages/ 4 | bower_components/ 5 | 6 | # Build output 7 | dist/ 8 | build/ 9 | 10 | # Environment files 11 | .env 12 | .env.local 13 | .env.development 14 | .env.test 15 | .env.production 16 | 17 | # Editor/IDE 18 | .vscode/ 19 | .idea/ 20 | 21 | # Logs and debug 22 | logs/ 23 | *.log 24 | npm-debug.log* 25 | yarn-debug.log* 26 | yarn-error.log* 27 | 28 | # Coverage 29 | coverage/ 30 | 31 | # TypeScript cache 32 | *.tsbuildinfo 33 | 34 | # Project specific 35 | .example/ 36 | .aider* 37 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Brad Fair 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Cline Personas MCP Server 2 | 3 | An MCP server for managing `.clinerules` files using shared components and persona templates. 4 | 5 | ## Features 6 | 7 | - **Component Management**: Create, read, update and delete reusable components 8 | - **Persona Templates**: Define persona templates with mustache-style variable substitution 9 | - **Dependency Validation**: Ensure persona templates only reference existing components 10 | - **Activation System**: Activate personas by writing to `.clinerules` file 11 | - **Version Tracking**: Track versions for both components and personas 12 | - **File-based Storage**: Store components and personas as JSON files 13 | 14 | ## Installation 15 | 16 | 1. Clone the repository 17 | 2. Install dependencies: 18 | ```bash 19 | npm install 20 | ``` 21 | 3. Build the project: 22 | ```bash 23 | npm run build 24 | ``` 25 | 26 | ## Usage 27 | 28 | ### Managing Components 29 | 30 | ```typescript 31 | import { ComponentPersonaService } from './src/service'; 32 | 33 | const service = new ComponentPersonaService(process.cwd()); 34 | 35 | // Create a new component 36 | service.setComponent('greeting', 'Welcome message', 'Hello {{name}}!', 1); 37 | 38 | // Get a component 39 | const component = service.getComponent('greeting'); 40 | 41 | // List all components 42 | const components = service.listComponents(); 43 | ``` 44 | 45 | ### Managing Personas 46 | 47 | ```typescript 48 | // Create a new persona 49 | service.setPersona( 50 | 'welcome', 51 | 'Welcome persona', 52 | '{{greeting}}\nPlease enjoy your stay!', 53 | 1 54 | ); 55 | 56 | // Activate a persona 57 | service.activatePersona('welcome'); 58 | 59 | // Get active persona 60 | const active = service.getActivePersona(); 61 | ``` 62 | 63 | ## File Structure 64 | 65 | ``` 66 | .cline-personas/ 67 | components/ 68 | [component-name].json 69 | personas/ 70 | [persona-name].json 71 | src/ 72 | component.ts # Component class and operations 73 | persona.ts # Persona class and template rendering 74 | service.ts # Main service implementation 75 | index.ts # MCP server entry point 76 | test/ # Unit tests 77 | ``` 78 | 79 | ## API Documentation 80 | 81 | ### ComponentPersonaService 82 | 83 | The main service class providing all operations: 84 | 85 | - **Component Operations**: 86 | - `setComponent(name, description, text, version)` 87 | - `getComponent(name)` 88 | - `listComponents()` 89 | - `deleteComponent(name)` 90 | 91 | - **Persona Operations**: 92 | - `setPersona(name, description, template, version)` 93 | - `getPersona(name)` 94 | - `listPersonas()` 95 | - `deletePersona(name)` 96 | - `activatePersona(name)` 97 | - `getActivePersona()` 98 | - `renderPersona(name)` 99 | 100 | ## Development 101 | 102 | Run tests: 103 | ```bash 104 | npm test 105 | ``` 106 | 107 | Build the project: 108 | ```bash 109 | npm run build 110 | ``` 111 | 112 | Run the MCP server: 113 | ```bash 114 | npm start 115 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | ['@babel/preset-env', { targets: { node: 'current' } }], 4 | '@babel/preset-typescript' 5 | ], 6 | plugins: [ 7 | '@babel/plugin-transform-modules-commonjs', 8 | '@babel/plugin-transform-runtime' 9 | ] 10 | }; 11 | module.exports = function (api) { 12 | api.cache(true); 13 | return { 14 | presets: [ 15 | ['@babel/preset-env', { targets: { node: 'current' } }], 16 | '@babel/preset-typescript' 17 | ], 18 | plugins: [ 19 | '@babel/plugin-transform-modules-commonjs' 20 | ] 21 | }; 22 | }; 23 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | const fs = require("fs"); 2 | const { pathsToModuleNameMapper } = require("ts-jest"); 3 | const { compilerOptions } = JSON.parse( 4 | fs.readFileSync("./tsconfig.json", "utf8") 5 | ); 6 | 7 | module.exports = { 8 | preset: "ts-jest/presets/default-esm", 9 | testEnvironment: "node", 10 | testMatch: ["**/test/**/*.test.ts"], 11 | reporters: ["jest-silent-reporter"], 12 | collectCoverage: true, 13 | coverageReporters: ["text", "lcov"], 14 | coverageDirectory: "coverage", 15 | moduleNameMapper: { 16 | "^@src/(.*)\\.js$": "/src/$1.ts", 17 | "^@src/(.*)$": "/src/$1.ts" 18 | }, 19 | moduleFileExtensions: ["js", "ts"], 20 | transformIgnorePatterns: ["node_modules/(?!(@modelcontextprotocol|ts-jest)/)"], 21 | roots: [""], 22 | extensionsToTreatAsEsm: [".ts"], 23 | transform: { 24 | "^.+\\.ts$": [ 25 | "ts-jest", 26 | { 27 | useESM: true 28 | } 29 | ] 30 | } 31 | }; 32 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cline-personas", 3 | "version": "0.1.0", 4 | "type": "module", 5 | "bin": { 6 | "cline-personas": "./dist/src/index.js" 7 | }, 8 | "files": [ 9 | "dist" 10 | ], 11 | "scripts": { 12 | "test": "echo 'showing only failing test and coverage data:' && NODE_NO_WARNINGS=1 node --experimental-vm-modules node_modules/.bin/jest", 13 | "build": "tsc && tsc-alias", 14 | "watch": "tsc -w", 15 | "dev": "ts-node -r tsconfig-paths/register src/index.ts" 16 | }, 17 | "author": "Brad Fair", 18 | "license": "MIT", 19 | "description": "An MCP Server that lets you implement personas in Cline via the `.clinerules` file.", 20 | "dependencies": { 21 | "@modelcontextprotocol/sdk": "^1.0.1", 22 | "zod": "^3.23.8", 23 | "zod-to-json-schema": "^3.23.5" 24 | }, 25 | "devDependencies": { 26 | "@babel/plugin-transform-modules-commonjs": "^7.26.3", 27 | "@babel/plugin-transform-runtime": "^7.25.9", 28 | "@babel/preset-env": "^7.26.0", 29 | "@types/jest": "^29.5.14", 30 | "@types/node": "^22.10.5", 31 | "babel-jest": "^29.7.0", 32 | "jest": "^29.7.0", 33 | "jest-silent-reporter": "^0.6.0", 34 | "ts-jest": "^29.2.5", 35 | "ts-node": "^10.9.2", 36 | "tsc-alias": "^1.8.10", 37 | "tsconfig-paths": "^4.2.0", 38 | "typescript": "^5.7.2", 39 | "vitest": "^1.5.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/component.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { logger } from '@src/logger.js'; 4 | 5 | export class Component { 6 | constructor( 7 | public name: string, 8 | public description: string, 9 | public text: string, 10 | public version: number 11 | ) {} 12 | 13 | saveToFile(filePath: string): void { 14 | try { 15 | const dir = path.dirname(filePath); 16 | if (!fs.existsSync(dir)) { 17 | fs.mkdirSync(dir, { recursive: true }); 18 | } 19 | 20 | fs.writeFileSync( 21 | filePath, 22 | JSON.stringify({ 23 | name: this.name, 24 | description: this.description, 25 | text: this.text, 26 | version: this.version 27 | }, null, 2) 28 | ); 29 | } catch (error) { 30 | throw new Error(`Failed to save component: ${error instanceof Error ? error.message : String(error)}`); 31 | } 32 | } 33 | 34 | static loadFromFile(filePath: string): Component { 35 | try { 36 | if (!fs.existsSync(filePath)) { 37 | throw new Error('File does not exist'); 38 | } 39 | 40 | const fileContent = fs.readFileSync(filePath, 'utf-8'); 41 | const data = JSON.parse(fileContent); 42 | 43 | if (!data.name || !data.description || !data.text || data.version === undefined) { 44 | throw new Error('Invalid component data in file'); 45 | } 46 | 47 | return new Component( 48 | data.name, 49 | data.description, 50 | data.text, 51 | Number(data.version) 52 | ); 53 | } catch (error) { 54 | const errorMsg = `Failed to load component: ${error}`; 55 | logger.error(errorMsg); 56 | throw new Error(errorMsg); 57 | } 58 | } 59 | 60 | equals(other: Component | null | undefined): boolean { 61 | if (!other) return false; 62 | return this.name === other.name && 63 | this.description === other.description && 64 | this.text === other.text && 65 | this.version === other.version; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "@src/server.js"; 2 | import { logger } from "@src/logger.js"; 3 | import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 4 | 5 | async function runServer() { 6 | logger.setLevel("info"); 7 | const { server } = createServer(); 8 | const transport = new StdioServerTransport(); 9 | await server.connect(transport); 10 | logger.info("Cline Persona Server is running"); 11 | } 12 | 13 | runServer().catch((error) => { 14 | logger.error("Fatal error running server:", error); 15 | process.exit(1); 16 | }); -------------------------------------------------------------------------------- /src/logger.ts: -------------------------------------------------------------------------------- 1 | import { format } from "util"; 2 | 3 | export class Logger { 4 | private static instance: Logger; 5 | private level: "debug" | "info" | "warn" | "error" = "info"; 6 | 7 | private constructor() {} 8 | 9 | public static getInstance(): Logger { 10 | if (!Logger.instance) { 11 | Logger.instance = new Logger(); 12 | } 13 | return Logger.instance; 14 | } 15 | 16 | public setLevel(level: "debug" | "info" | "warn" | "error"): void { 17 | this.level = level; 18 | } 19 | 20 | public debug(message: string, ...args: any[]): void { 21 | if (this.shouldLog("debug")) { 22 | console.error(`[DEBUG] ${format(message, ...args)}`); 23 | } 24 | } 25 | 26 | public info(message: string, ...args: any[]): void { 27 | if (this.shouldLog("info")) { 28 | console.error(`[INFO] ${format(message, ...args)}`); 29 | } 30 | } 31 | 32 | public warn(message: string, ...args: any[]): void { 33 | if (this.shouldLog("warn")) { 34 | console.warn(`[WARN] ${format(message, ...args)}`); 35 | } 36 | } 37 | 38 | public error(message: string, ...args: any[]): void { 39 | if (this.shouldLog("error")) { 40 | console.error(`[ERROR] ${format(message, ...args)}`); 41 | } 42 | } 43 | 44 | private shouldLog(level: string): boolean { 45 | const levels = ["debug", "info", "warn", "error"]; 46 | return levels.indexOf(level) >= levels.indexOf(this.level); 47 | } 48 | } 49 | 50 | export const logger = Logger.getInstance(); 51 | -------------------------------------------------------------------------------- /src/persona.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import path from 'path'; 3 | import { logger } from '@src/logger.js'; 4 | 5 | export class Persona { 6 | constructor( 7 | public name: string, 8 | public description: string, 9 | public template: string, 10 | public version: number 11 | ) {} 12 | 13 | saveToFile(filePath: string): void { 14 | try { 15 | const dir = path.dirname(filePath); 16 | if (!fs.existsSync(dir)) { 17 | fs.mkdirSync(dir, { recursive: true }); 18 | } 19 | 20 | fs.writeFileSync( 21 | filePath, 22 | JSON.stringify({ 23 | name: this.name, 24 | description: this.description, 25 | template: this.template, 26 | version: this.version 27 | }, null, 2) 28 | ); 29 | } catch (error) { 30 | throw new Error(`Failed to save persona: ${error instanceof Error ? error.message : String(error)}`); 31 | } 32 | } 33 | 34 | static loadFromFile(filePath: string): Persona { 35 | try { 36 | if (!fs.existsSync(filePath)) { 37 | throw new Error('File does not exist'); 38 | } 39 | 40 | const fileContent = fs.readFileSync(filePath, 'utf-8'); 41 | const data = JSON.parse(fileContent); 42 | 43 | if (!data.name || !data.description || !data.template || data.version === undefined) { 44 | throw new Error('Invalid persona data in file'); 45 | } 46 | 47 | return new Persona( 48 | data.name, 49 | data.description, 50 | data.template, 51 | Number(data.version) 52 | ); 53 | } catch (error) { 54 | const errorMsg = `Failed to load persona: ${error}`; 55 | logger.error(errorMsg); 56 | throw new Error(errorMsg); 57 | } 58 | } 59 | 60 | equals(other: Persona | null | undefined): boolean { 61 | if (!other) return false; 62 | return this.name === other.name && 63 | this.description === other.description && 64 | this.template === other.template && 65 | this.version === other.version; 66 | } 67 | 68 | render(data: Record): string { 69 | // Create a normalized data object with lowercase keys 70 | const normalizedData = Object.fromEntries( 71 | Object.entries(data).map(([key, value]) => [key.toLowerCase(), value]) 72 | ); 73 | 74 | return this.template.replace(/\{\{\s*([^}]+)\s*\}\}/g, (match, varName) => { 75 | const normalizedVar = varName.trim().toLowerCase(); 76 | return normalizedData[normalizedVar] !== undefined ? normalizedData[normalizedVar] : match; 77 | }); 78 | } 79 | 80 | requiredComponents(): string[] { 81 | const matches = this.template.match(/\{\{\s*([^}]+)\s*\}\}/g); 82 | if (!matches) return []; 83 | 84 | const components = new Set(); 85 | for (const match of matches) { 86 | const varName = match.replace(/\{\{\s*|\s*\}\}/g, '').toLowerCase(); 87 | if (varName) { 88 | components.add(varName); 89 | } 90 | } 91 | return Array.from(components); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/server.ts: -------------------------------------------------------------------------------- 1 | import { Server } from "@modelcontextprotocol/sdk/server/index.js"; 2 | import { logger } from '@src/logger.js'; 3 | import { 4 | CallToolRequestSchema, 5 | ListToolsRequestSchema, 6 | Tool, 7 | } from "@modelcontextprotocol/sdk/types.js"; 8 | import { z } from "zod"; 9 | import { zodToJsonSchema } from "zod-to-json-schema"; 10 | import { ComponentPersonaService } from "@src/service.js"; 11 | 12 | type ToolInput = { 13 | type: "object"; 14 | properties?: Record; 15 | required?: string[]; 16 | [key: string]: unknown; 17 | }; 18 | 19 | enum ToolName { 20 | LIST_PERSONAS = "listPersonas", 21 | LIST_COMPONENTS = "listComponents", 22 | CREATE_OR_UPDATE_PERSONA = "createOrUpdatePersona", 23 | CREATE_OR_UPDATE_COMPONENT = "createOrUpdateComponent", 24 | DELETE_PERSONA = "deletePersona", 25 | DELETE_COMPONENT = "deleteComponent", 26 | ACTIVATE_PERSONA = "activatePersona", 27 | GET_ACTIVE_PERSONA = "getActivePersona" 28 | } 29 | 30 | export const createServer = () => { 31 | logger.info(`Initializing server`); 32 | const service = new ComponentPersonaService(); 33 | const server = new Server( 34 | { 35 | name: "cline-persona-server", 36 | version: "0.1.0", 37 | }, 38 | { 39 | capabilities: { 40 | tools: {}, 41 | }, 42 | } 43 | ); 44 | 45 | // Define tool schemas based on service.ts implementation 46 | const ListPersonasSchema = z.object({ 47 | projectRoot: z.string().describe('Root directory path of the cline project') 48 | }); 49 | const ListComponentsSchema = z.object({ 50 | projectRoot: z.string().describe('Root directory path of the cline project') 51 | }); 52 | const CreateOrUpdatePersonaSchema = z.object({ 53 | projectRoot: z.string().describe('Root directory path of the cline project'), 54 | name: z.string().describe('Unique identifier name for the persona'), 55 | description: z.string().describe('Detailed description of the persona\'s purpose and behavior'), 56 | template: z.string().describe('Template content defining the persona\'s characteristics'), 57 | version: z.number().describe('Version number for tracking persona updates') 58 | }); 59 | 60 | const CreateOrUpdateComponentSchema = z.object({ 61 | projectRoot: z.string().describe('Root directory path of the cline project'), 62 | name: z.string().describe('Unique identifier name for the component'), 63 | description: z.string().describe('Detailed description of the component\'s purpose and functionality'), 64 | text: z.string().describe('Content/implementation of the component'), 65 | version: z.number().describe('Version number for tracking component updates') 66 | }); 67 | 68 | const DeletePersonaSchema = z.object({ 69 | projectRoot: z.string().describe('Root directory path of the cline project'), 70 | name: z.string().describe('Name of the persona to delete') 71 | }); 72 | 73 | const DeleteComponentSchema = z.object({ 74 | projectRoot: z.string().describe('Root directory path of the cline project'), 75 | name: z.string().describe('Name of the component to delete') 76 | }); 77 | const ActivatePersonaSchema = z.object({ 78 | projectRoot: z.string().describe('Root directory path of the cline project'), 79 | name: z.string().describe('Name of the persona to activate') 80 | }); 81 | const GetActivePersonaSchema = z.object({ 82 | projectRoot: z.string().describe('Root directory path of the cline project') 83 | }); 84 | 85 | // Setup tool handlers 86 | server.setRequestHandler(ListToolsRequestSchema, async () => { 87 | const tools: Tool[] = [ 88 | { 89 | name: ToolName.LIST_PERSONAS, 90 | description: "List all available personas", 91 | inputSchema: { 92 | type: "object", 93 | ...zodToJsonSchema(ListPersonasSchema) 94 | } as ToolInput 95 | }, 96 | { 97 | name: ToolName.LIST_COMPONENTS, 98 | description: "List all available components", 99 | inputSchema: { 100 | type: "object", 101 | ...zodToJsonSchema(ListComponentsSchema) 102 | } as ToolInput 103 | }, 104 | { 105 | name: ToolName.CREATE_OR_UPDATE_PERSONA, 106 | description: "Create or update a persona", 107 | inputSchema: { 108 | type: "object", 109 | ...zodToJsonSchema(CreateOrUpdatePersonaSchema) 110 | } as ToolInput 111 | }, 112 | { 113 | name: ToolName.CREATE_OR_UPDATE_COMPONENT, 114 | description: "Create or update a component", 115 | inputSchema: { 116 | type: "object", 117 | ...zodToJsonSchema(CreateOrUpdateComponentSchema) 118 | } as ToolInput 119 | }, 120 | { 121 | name: ToolName.DELETE_PERSONA, 122 | description: "Delete a persona", 123 | inputSchema: { 124 | type: "object", 125 | ...zodToJsonSchema(DeletePersonaSchema) 126 | } as ToolInput 127 | }, 128 | { 129 | name: ToolName.DELETE_COMPONENT, 130 | description: "Delete a component", 131 | inputSchema: { 132 | type: "object", 133 | ...zodToJsonSchema(DeleteComponentSchema) 134 | } as ToolInput 135 | }, 136 | { 137 | name: ToolName.ACTIVATE_PERSONA, 138 | description: "Activate a specific persona", 139 | inputSchema: { 140 | type: "object", 141 | ...zodToJsonSchema(ActivatePersonaSchema) 142 | } as ToolInput 143 | }, 144 | { 145 | name: ToolName.GET_ACTIVE_PERSONA, 146 | description: "Get the currently active persona", 147 | inputSchema: { 148 | type: "object", 149 | ...zodToJsonSchema(GetActivePersonaSchema) 150 | } as ToolInput 151 | } 152 | ]; 153 | 154 | return { tools }; 155 | }); 156 | 157 | server.setRequestHandler(CallToolRequestSchema, async (request) => { 158 | const { name, arguments: args } = request.params; 159 | logger.info(`Executing tool: ${name}`, { args }); 160 | 161 | try { 162 | switch (name) { 163 | case ToolName.LIST_PERSONAS: 164 | const listPersonasArgs = ListPersonasSchema.parse(args); 165 | return { 166 | content: [{ 167 | type: "text", 168 | text: JSON.stringify(service.listPersonas(listPersonasArgs.projectRoot)) 169 | }] 170 | }; 171 | case ToolName.LIST_COMPONENTS: 172 | const listComponentsArgs = ListComponentsSchema.parse(args); 173 | return { 174 | content: [{ 175 | type: "text", 176 | text: JSON.stringify(service.listComponents(listComponentsArgs.projectRoot)) 177 | }] 178 | }; 179 | case ToolName.CREATE_OR_UPDATE_PERSONA: 180 | const createPersonaArgs = CreateOrUpdatePersonaSchema.parse(args); 181 | service.setPersona( 182 | createPersonaArgs.projectRoot, 183 | createPersonaArgs.name, 184 | createPersonaArgs.description, 185 | createPersonaArgs.template, 186 | createPersonaArgs.version 187 | ); 188 | return { 189 | content: [{ 190 | type: "text", 191 | text: JSON.stringify({ success: true }) 192 | }] 193 | }; 194 | case ToolName.CREATE_OR_UPDATE_COMPONENT: 195 | const createComponentArgs = CreateOrUpdateComponentSchema.parse(args); 196 | service.setComponent( 197 | createComponentArgs.projectRoot, 198 | createComponentArgs.name, 199 | createComponentArgs.description, 200 | createComponentArgs.text, 201 | createComponentArgs.version 202 | ); 203 | return { 204 | content: [{ 205 | type: "text", 206 | text: JSON.stringify({ success: true }) 207 | }] 208 | }; 209 | case ToolName.DELETE_PERSONA: 210 | const deletePersonaArgs = DeletePersonaSchema.parse(args); 211 | service.deletePersona(deletePersonaArgs.projectRoot, deletePersonaArgs.name); 212 | return { 213 | content: [{ 214 | type: "text", 215 | text: JSON.stringify({ success: true }) 216 | }] 217 | }; 218 | case ToolName.DELETE_COMPONENT: 219 | const deleteComponentArgs = DeleteComponentSchema.parse(args); 220 | service.deleteComponent(deleteComponentArgs.projectRoot, deleteComponentArgs.name); 221 | return { 222 | content: [{ 223 | type: "text", 224 | text: JSON.stringify({ success: true }) 225 | }] 226 | }; 227 | case ToolName.ACTIVATE_PERSONA: 228 | const activatePersonaArgs = ActivatePersonaSchema.parse(args); 229 | service.activatePersona(activatePersonaArgs.projectRoot, activatePersonaArgs.name); 230 | return { 231 | content: [{ 232 | type: "text", 233 | text: JSON.stringify({ success: true }) 234 | }] 235 | }; 236 | case ToolName.GET_ACTIVE_PERSONA: 237 | const getActivePersonaArgs = GetActivePersonaSchema.parse(args); 238 | const persona = service.getActivePersona(getActivePersonaArgs.projectRoot); 239 | if (!persona) { 240 | return { 241 | content: [] 242 | }; 243 | } 244 | return { 245 | content: [{ 246 | type: "text", 247 | text: persona 248 | }] 249 | }; 250 | default: 251 | throw new Error(`Unknown tool: ${name}`); 252 | } 253 | } catch (error) { 254 | logger.error(`Error executing tool ${name}:`, { error }); 255 | throw error; 256 | } 257 | }); 258 | 259 | return { server, service }; 260 | }; 261 | -------------------------------------------------------------------------------- /src/service.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path from "path"; 3 | import { Component } from "@src/component.js"; 4 | import { Persona } from "@src/persona.js"; 5 | import { logger } from "@src/logger.js"; 6 | 7 | const serviceDirectoryName = ".cline-personas"; 8 | 9 | export interface ComponentService { 10 | setComponent( 11 | projectRoot: string, 12 | name: string, 13 | description: string, 14 | text: string, 15 | version: number 16 | ): void; 17 | getComponent(projectRoot: string, name: string): Component | null; 18 | listComponents(projectRoot: string): string[]; 19 | deleteComponent(projectRoot: string, name: string): void; 20 | describeComponents(projectRoot: string): Map; 21 | } 22 | 23 | export interface PersonaService { 24 | setPersona( 25 | projectRoot: string, 26 | name: string, 27 | description: string, 28 | template: string, 29 | version: number 30 | ): void; 31 | getPersona(projectRoot: string, name: string): Persona | null; 32 | listPersonas(projectRoot: string): string[]; 33 | deletePersona(projectRoot: string, name: string): void; 34 | describePersonas(projectRoot: string): Map; 35 | } 36 | 37 | export class ComponentPersonaService 38 | implements ComponentService, PersonaService 39 | { 40 | private getComponentRoot(projectRoot: string): string { 41 | if (!fs.existsSync(path.join(projectRoot, serviceDirectoryName))) { 42 | logger.debug(`Creating service directory at ${path.join(projectRoot, serviceDirectoryName)}`); 43 | fs.mkdirSync(path.join(projectRoot, serviceDirectoryName), { recursive: true }); 44 | } 45 | const componentRoot = path.join(projectRoot, serviceDirectoryName, "components"); 46 | if (!fs.existsSync(componentRoot)) { 47 | logger.debug(`Creating component directory at ${componentRoot}`); 48 | fs.mkdirSync(componentRoot, { recursive: true }); 49 | } 50 | return componentRoot; 51 | } 52 | 53 | private getPersonaRoot(projectRoot: string): string { 54 | if (!fs.existsSync(path.join(projectRoot, serviceDirectoryName))) { 55 | logger.debug(`Creating service directory at ${path.join(projectRoot, serviceDirectoryName)}`); 56 | fs.mkdirSync(path.join(projectRoot, serviceDirectoryName), { recursive: true }); 57 | } 58 | const personaRoot = path.join(projectRoot, serviceDirectoryName, "personas"); 59 | if (!fs.existsSync(personaRoot)) { 60 | logger.debug(`Creating persona directory at ${personaRoot}`); 61 | fs.mkdirSync(personaRoot, { recursive: true }); 62 | } 63 | return personaRoot; 64 | } 65 | 66 | private getComponentPath(projectRoot: string, name: string): string { 67 | const componentRoot = this.getComponentRoot(projectRoot); 68 | return path.join(componentRoot, `${name}.json`); 69 | } 70 | 71 | private getPersonaPath(projectRoot: string, name: string): string { 72 | const personaRoot = this.getPersonaRoot(projectRoot); 73 | return path.join(personaRoot, `${name}.json`); 74 | } 75 | 76 | // Component operations 77 | setComponent( 78 | projectRoot: string, 79 | name: string, 80 | description: string, 81 | text: string, 82 | version: number 83 | ): void { 84 | logger.info(`Setting component ${name} (version ${version})`); 85 | const component = new Component(name, description, text, version); 86 | const filePath = this.getComponentPath(projectRoot, name); 87 | logger.debug(`Saving component to ${filePath}`); 88 | component.saveToFile(filePath); 89 | } 90 | 91 | getComponent(projectRoot: string, name: string): Component | null { 92 | const filePath = this.getComponentPath(projectRoot, name); 93 | logger.debug(`Loading component ${name} from ${filePath}`); 94 | if (!fs.existsSync(filePath)) { 95 | logger.warn(`Component file not found: ${filePath}`); 96 | return null; 97 | } 98 | return Component.loadFromFile(filePath); 99 | } 100 | 101 | listComponents(projectRoot: string): string[] { 102 | const componentRoot = this.getComponentRoot(projectRoot); 103 | logger.debug(`Listing components from ${componentRoot}`); 104 | return fs 105 | .readdirSync(componentRoot) 106 | .filter((file) => file.endsWith(".json")) 107 | .map((file) => path.basename(file, ".json")); 108 | } 109 | 110 | deleteComponent(projectRoot: string, name: string): void { 111 | logger.info(`Attempting to delete component ${name}`); 112 | const personas = this.listPersonas(projectRoot); 113 | let dependents = []; 114 | for (const personaName of personas) { 115 | const persona = this.getPersona(projectRoot, personaName); 116 | if (persona && persona.requiredComponents().includes(name)) { 117 | dependents.push(personaName); 118 | } 119 | } 120 | if (dependents.length > 0) { 121 | const errorMsg = `Cannot delete component: required by personas: ${dependents.join(", ")}`; 122 | logger.error(errorMsg); 123 | throw new Error(errorMsg); 124 | } 125 | 126 | const filePath = this.getComponentPath(projectRoot, name); 127 | if (fs.existsSync(filePath)) { 128 | logger.debug(`Deleting component file at ${filePath}`); 129 | fs.unlinkSync(filePath); 130 | } else { 131 | logger.warn(`Component file not found: ${filePath}`); 132 | } 133 | } 134 | 135 | // Persona operations 136 | setPersona( 137 | projectRoot: string, 138 | name: string, 139 | description: string, 140 | template: string, 141 | version: number 142 | ): void { 143 | logger.info(`Setting persona ${name} (version ${version})`); 144 | const persona = new Persona(name, description, template, version); 145 | 146 | // Validate that all template variables exist as components 147 | const templateComponents = persona.requiredComponents(); 148 | for (const componentName of templateComponents) { 149 | if (!this.getComponent(projectRoot, componentName)) { 150 | const errorMsg = `Cannot save persona: depends on non-existent component: ${componentName}`; 151 | logger.error(errorMsg); 152 | throw new Error(errorMsg); 153 | } 154 | } 155 | 156 | const filePath = this.getPersonaPath(projectRoot, name); 157 | logger.debug(`Saving persona to ${filePath}`); 158 | persona.saveToFile(filePath); 159 | } 160 | 161 | activatePersona(projectRoot: string, name: string): void { 162 | logger.info(`Activating persona ${name}`); 163 | const persona = this.getPersona(projectRoot, name); 164 | if (!persona) { 165 | const errorMsg = `Persona not found: ${name}`; 166 | logger.error(errorMsg); 167 | throw new Error(errorMsg); 168 | } 169 | 170 | const clinerulesPath = path.join(projectRoot, ".clinerules"); 171 | logger.debug(`Writing persona template to ${clinerulesPath}`); 172 | fs.writeFileSync(clinerulesPath, persona.template); 173 | } 174 | 175 | getActivePersona(projectRoot: string): string | null { 176 | logger.debug(`Getting active persona`); 177 | const clinerulesPath = path.join(projectRoot, ".clinerules"); 178 | if (!fs.existsSync(clinerulesPath)) { 179 | logger.debug(`No active persona found - .clinerules file missing`); 180 | return null; 181 | } 182 | 183 | const currentClineRules = fs.readFileSync(clinerulesPath, "utf-8"); 184 | 185 | // Find the active persona by comparing rendered personas with the current .clinerules file 186 | const personas = this.listPersonas(projectRoot); 187 | for (const personaName of personas) { 188 | const renderedPersona = this.renderPersona(projectRoot, personaName); 189 | if (renderedPersona === currentClineRules) { 190 | logger.debug(`Active persona found: ${personaName}`); 191 | return personaName; 192 | } 193 | } 194 | 195 | logger.debug(`No matching active persona found`); 196 | return null; 197 | } 198 | 199 | getPersona(projectRoot: string, name: string): Persona | null { 200 | const filePath = this.getPersonaPath(projectRoot, name); 201 | logger.debug(`Loading persona ${name} from ${filePath}`); 202 | if (!fs.existsSync(filePath)) { 203 | logger.warn(`Persona file not found: ${filePath}`); 204 | return null; 205 | } 206 | return Persona.loadFromFile(filePath); 207 | } 208 | 209 | listPersonas(projectRoot: string): string[] { 210 | const personaRoot = this.getPersonaRoot(projectRoot); 211 | logger.debug(`Listing personas from ${personaRoot}`); 212 | return fs 213 | .readdirSync(personaRoot) 214 | .filter((file) => file.endsWith(".json")) 215 | .map((file) => path.basename(file, ".json")); 216 | } 217 | 218 | deletePersona(projectRoot: string, name: string): void { 219 | logger.info(`Deleting persona ${name}`); 220 | const filePath = this.getPersonaPath(projectRoot, name); 221 | if (fs.existsSync(filePath)) { 222 | logger.debug(`Deleting persona file at ${filePath}`); 223 | fs.unlinkSync(filePath); 224 | } else { 225 | logger.warn(`Persona file not found: ${filePath}`); 226 | } 227 | } 228 | 229 | describePersonas(projectRoot: string): Map { 230 | logger.debug(`Describing personas`); 231 | const personaMap = new Map(); 232 | for (const name of this.listPersonas(projectRoot)) { 233 | const persona = this.getPersona(projectRoot, name); 234 | if (persona) { 235 | personaMap.set(name, persona.description); 236 | } 237 | } 238 | return personaMap; 239 | } 240 | 241 | describeComponents(projectRoot: string): Map { 242 | logger.debug(`Describing components`); 243 | const componentMap = new Map(); 244 | for (const name of this.listComponents(projectRoot)) { 245 | const component = this.getComponent(projectRoot, name); 246 | if (component) { 247 | componentMap.set(name, component.description); 248 | } 249 | } 250 | return componentMap; 251 | } 252 | 253 | renderPersona(projectRoot: string, name: string): string { 254 | logger.debug(`Rendering persona ${name}`); 255 | const persona = this.getPersona(projectRoot, name); 256 | if (!persona) { 257 | const errorMsg = `Persona not found: ${name}`; 258 | logger.error(errorMsg); 259 | throw new Error(errorMsg); 260 | } 261 | 262 | // Get all required components and their texts 263 | const data: Record = {}; 264 | for (const componentName of persona.requiredComponents()) { 265 | const component = this.getComponent(projectRoot, componentName); 266 | if (!component) { 267 | const errorMsg = `Cannot render persona: missing required component: ${componentName}`; 268 | logger.error(errorMsg); 269 | throw new Error(errorMsg); 270 | } 271 | data[componentName] = component.text; 272 | } 273 | 274 | return persona.render(data); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /test/component.test.ts: -------------------------------------------------------------------------------- 1 | import { Component } from "@src/component.js"; 2 | import fs from "fs"; 3 | import path, { dirname } from "path"; 4 | import { fileURLToPath } from "url"; 5 | import { jest } from "@jest/globals"; 6 | 7 | const __filename = fileURLToPath(import.meta.url); 8 | const __dirname = dirname(__filename); 9 | 10 | describe("Component", () => { 11 | let tempDir: string; 12 | let testFilePath: string; 13 | 14 | beforeAll(() => { 15 | tempDir = fs.mkdtempSync(path.join(__dirname, "component-test-")); 16 | testFilePath = path.join(tempDir, "test-component.json"); 17 | }); 18 | 19 | afterEach(() => { 20 | if (fs.existsSync(testFilePath)) { 21 | fs.unlinkSync(testFilePath); 22 | } 23 | }); 24 | 25 | afterAll(() => { 26 | if (fs.existsSync(tempDir)) { 27 | fs.rmSync(tempDir, { recursive: true }); 28 | } 29 | }); 30 | 31 | describe("constructor", () => { 32 | it("should create a component with all fields", () => { 33 | const component = new Component("Test", "Description", "Text", 1); 34 | expect(component.name).toBe("Test"); 35 | expect(component.description).toBe("Description"); 36 | expect(component.text).toBe("Text"); 37 | expect(component.version).toBe(1); 38 | }); 39 | }); 40 | 41 | describe("saveToFile", () => { 42 | it("should save component as JSON file", () => { 43 | const component = new Component("Test", "Description", "Text", 1); 44 | component.saveToFile(testFilePath); 45 | 46 | expect(fs.existsSync(testFilePath)).toBe(true); 47 | 48 | const fileContent = JSON.parse(fs.readFileSync(testFilePath, "utf-8")); 49 | expect(fileContent).toEqual({ 50 | name: "Test", 51 | description: "Description", 52 | text: "Text", 53 | version: 1, 54 | }); 55 | }); 56 | 57 | it("should throw error for invalid path", () => { 58 | const component = new Component("Test", "Description", "Text", 1); 59 | expect(() => component.saveToFile("/invalid/path/test.json")).toThrow(); 60 | }); 61 | 62 | it("should throw error when trying to write to read-only file", () => { 63 | const mockWrite = jest 64 | .spyOn(fs, "writeFileSync") 65 | .mockImplementation(() => { 66 | throw new Error("EPERM: operation not permitted, open"); 67 | }); 68 | 69 | const component = new Component("Test", "Description", "Text", 1); 70 | expect(() => component.saveToFile(testFilePath)).toThrow( 71 | "Failed to save component: EPERM: operation not permitted, open" 72 | ); 73 | 74 | mockWrite.mockRestore(); 75 | }); 76 | }); 77 | 78 | describe("loadFromFile", () => { 79 | it("should load component from JSON file", () => { 80 | const originalComponent = new Component("Test", "Description", "Text", 1); 81 | originalComponent.saveToFile(testFilePath); 82 | 83 | const loadedComponent = Component.loadFromFile(testFilePath); 84 | expect(loadedComponent).toBeInstanceOf(Component); 85 | expect(loadedComponent).toEqual(originalComponent); 86 | }); 87 | 88 | it("should throw error for non-existent file", () => { 89 | expect(() => Component.loadFromFile("/nonexistent/file.json")).toThrow(); 90 | }); 91 | 92 | it("should throw error for invalid JSON", () => { 93 | fs.writeFileSync(testFilePath, "invalid json"); 94 | expect(() => Component.loadFromFile(testFilePath)).toThrow(); 95 | }); 96 | 97 | it("should throw error when trying to read from write-only file", () => { 98 | const originalRead = fs.readFileSync; 99 | const originalExists = fs.existsSync; 100 | try { 101 | fs.readFileSync = jest.fn().mockImplementation((path: fs.PathOrFileDescriptor, options?: { encoding?: BufferEncoding | null; flag?: string } | null | BufferEncoding) => { 102 | throw new Error("EACCES: permission denied, open"); 103 | }) as jest.MockedFunction; 104 | fs.existsSync = jest.fn().mockReturnValue(true) as jest.MockedFunction; 105 | 106 | expect(() => Component.loadFromFile(testFilePath)).toThrow( 107 | "Failed to load component: Error: EACCES: permission denied, open" 108 | ); 109 | } finally { 110 | fs.readFileSync = originalRead; 111 | fs.existsSync = originalExists; 112 | } 113 | }); 114 | 115 | it("should throw error for JSON missing required fields", () => { 116 | const invalidData = { 117 | name: "Test", 118 | description: "Description", 119 | // Missing text and version 120 | }; 121 | fs.writeFileSync(testFilePath, JSON.stringify(invalidData)); 122 | 123 | expect(() => Component.loadFromFile(testFilePath)).toThrow( 124 | "Invalid component data in file" 125 | ); 126 | }); 127 | }); 128 | 129 | describe("equals", () => { 130 | let component: Component; 131 | 132 | beforeEach(() => { 133 | component = new Component("Test", "Description", "Text", 1); 134 | }); 135 | 136 | it("should return true for identical components", () => { 137 | const other = new Component("Test", "Description", "Text", 1); 138 | expect(component.equals(other)).toBe(true); 139 | }); 140 | 141 | it("should return false for different names", () => { 142 | const other = new Component("Different", "Description", "Text", 1); 143 | expect(component.equals(other)).toBe(false); 144 | }); 145 | 146 | it("should return false for different descriptions", () => { 147 | const other = new Component("Test", "Different", "Text", 1); 148 | expect(component.equals(other)).toBe(false); 149 | }); 150 | 151 | it("should return false for different text", () => { 152 | const other = new Component("Test", "Description", "Different", 1); 153 | expect(component.equals(other)).toBe(false); 154 | }); 155 | 156 | it("should return false for different versions", () => { 157 | const other = new Component("Test", "Description", "Text", 2); 158 | expect(component.equals(other)).toBe(false); 159 | }); 160 | 161 | it("should return false when comparing with null", () => { 162 | expect(component.equals(null)).toBe(false); 163 | }); 164 | 165 | it("should return false when comparing with undefined", () => { 166 | expect(component.equals(undefined)).toBe(false); 167 | }); 168 | }); 169 | }); 170 | -------------------------------------------------------------------------------- /test/logger.test.ts: -------------------------------------------------------------------------------- 1 | import { Logger, logger } from "@src/logger.js"; 2 | import { jest } from "@jest/globals"; 3 | 4 | describe("Logger", () => { 5 | let originalConsole: any; 6 | 7 | beforeEach(() => { 8 | originalConsole = { ...console }; 9 | jest.spyOn(console, "debug").mockImplementation(() => {}); 10 | jest.spyOn(console, "info").mockImplementation(() => {}); 11 | jest.spyOn(console, "warn").mockImplementation(() => {}); 12 | jest.spyOn(console, "error").mockImplementation(() => {}); 13 | }); 14 | 15 | afterEach(() => { 16 | jest.clearAllMocks(); 17 | Object.assign(console, originalConsole); 18 | }); 19 | 20 | it("should be a singleton", () => { 21 | const logger1 = Logger.getInstance(); 22 | const logger2 = Logger.getInstance(); 23 | expect(logger1).toBe(logger2); 24 | }); 25 | 26 | it("should log debug messages when level is debug", () => { 27 | logger.setLevel("debug"); 28 | logger.debug("test message"); 29 | expect(console.error).toHaveBeenCalledWith("[DEBUG] test message"); 30 | }); 31 | 32 | it("should not log debug messages when level is info", () => { 33 | logger.setLevel("info"); 34 | logger.debug("test message"); 35 | expect(console.error).not.toHaveBeenCalled(); 36 | }); 37 | 38 | it("should log info messages when level is info", () => { 39 | logger.setLevel("info"); 40 | logger.info("test message"); 41 | expect(console.error).toHaveBeenCalledWith("[INFO] test message"); 42 | }); 43 | 44 | it("should log warn messages when level is warn", () => { 45 | logger.setLevel("warn"); 46 | logger.warn("test message"); 47 | expect(console.warn).toHaveBeenCalledWith("[WARN] test message"); 48 | }); 49 | 50 | it("should log error messages when level is error", () => { 51 | logger.setLevel("error"); 52 | logger.error("test message"); 53 | expect(console.error).toHaveBeenCalledWith("[ERROR] test message"); 54 | }); 55 | 56 | it("should format messages with arguments", () => { 57 | logger.setLevel("info"); 58 | logger.info("test %s", "message"); 59 | expect(console.error).toHaveBeenCalledWith("[INFO] test message"); 60 | }); 61 | }); 62 | -------------------------------------------------------------------------------- /test/persona.test.ts: -------------------------------------------------------------------------------- 1 | import { Persona } from "@src/persona.js"; 2 | import fs from "fs"; 3 | import path, { dirname } from "path"; 4 | import { fileURLToPath } from "url"; 5 | import { jest } from "@jest/globals"; 6 | const __filename = fileURLToPath(import.meta.url); 7 | const __dirname = dirname(__filename); 8 | 9 | describe("Persona", () => { 10 | let tempDir: string; 11 | let testFilePath: string; 12 | 13 | beforeAll(() => { 14 | tempDir = fs.mkdtempSync(path.join(__dirname, "persona-test-")); 15 | testFilePath = path.join(tempDir, "test-persona.json"); 16 | }); 17 | 18 | afterEach(() => { 19 | if (fs.existsSync(testFilePath)) { 20 | fs.unlinkSync(testFilePath); 21 | } 22 | }); 23 | 24 | afterAll(() => { 25 | if (fs.existsSync(tempDir)) { 26 | fs.rmSync(tempDir, { recursive: true }); 27 | } 28 | }); 29 | 30 | describe("constructor", () => { 31 | it("should create a persona with all fields", () => { 32 | const persona = new Persona("Test", "Description", "Template", 1); 33 | expect(persona.name).toBe("Test"); 34 | expect(persona.description).toBe("Description"); 35 | expect(persona.template).toBe("Template"); 36 | expect(persona.version).toBe(1); 37 | }); 38 | }); 39 | 40 | describe("saveToFile", () => { 41 | it("should save persona as JSON file", () => { 42 | const persona = new Persona("Test", "Description", "Template", 1); 43 | persona.saveToFile(testFilePath); 44 | 45 | expect(fs.existsSync(testFilePath)).toBe(true); 46 | 47 | const fileContent = JSON.parse(fs.readFileSync(testFilePath, "utf-8")); 48 | expect(fileContent).toEqual({ 49 | name: "Test", 50 | description: "Description", 51 | template: "Template", 52 | version: 1, 53 | }); 54 | }); 55 | 56 | it("should throw error for invalid path", () => { 57 | const persona = new Persona("Test", "Description", "Template", 1); 58 | expect(() => persona.saveToFile("/invalid/path/test.json")).toThrow(); 59 | }); 60 | 61 | it("should throw error when trying to write to read-only file", () => { 62 | const mockWrite = jest 63 | .spyOn(fs, "writeFileSync") 64 | .mockImplementation(() => { 65 | throw new Error("EPERM: operation not permitted, open"); 66 | }); 67 | 68 | const persona = new Persona("Test", "Description", "Template", 1); 69 | expect(() => persona.saveToFile(testFilePath)).toThrow( 70 | "Failed to save persona: EPERM: operation not permitted, open" 71 | ); 72 | 73 | mockWrite.mockRestore(); 74 | }); 75 | }); 76 | 77 | describe("loadFromFile", () => { 78 | it("should load persona from JSON file", () => { 79 | const originalPersona = new Persona("Test", "Description", "Template", 1); 80 | originalPersona.saveToFile(testFilePath); 81 | 82 | const loadedPersona = Persona.loadFromFile(testFilePath); 83 | expect(loadedPersona).toBeInstanceOf(Persona); 84 | expect(loadedPersona).toEqual(originalPersona); 85 | }); 86 | 87 | it("should throw error for non-existent file", () => { 88 | expect(() => Persona.loadFromFile("/nonexistent/file.json")).toThrow(); 89 | }); 90 | 91 | it("should throw error for invalid JSON", () => { 92 | fs.writeFileSync(testFilePath, "invalid json"); 93 | expect(() => Persona.loadFromFile(testFilePath)).toThrow(); 94 | }); 95 | 96 | it("should throw error when trying to read from write-only file", () => { 97 | const originalRead = fs.readFileSync; 98 | const originalExists = fs.existsSync; 99 | try { 100 | fs.readFileSync = jest.fn().mockImplementation((path: fs.PathOrFileDescriptor, options?: { encoding?: BufferEncoding | null; flag?: string } | null | BufferEncoding) => { 101 | throw new Error("EACCES: permission denied, open"); 102 | }) as jest.MockedFunction; 103 | fs.existsSync = jest.fn().mockReturnValue(true); 104 | 105 | expect(() => Persona.loadFromFile(testFilePath)).toThrow( 106 | "Failed to load persona: Error: EACCES: permission denied, open" 107 | ); 108 | } finally { 109 | fs.readFileSync = originalRead; 110 | fs.existsSync = originalExists; 111 | } 112 | }); 113 | 114 | it("should throw error for JSON missing required fields", () => { 115 | const invalidData = { 116 | name: "Test", 117 | description: "Description", 118 | // Missing template and version 119 | }; 120 | fs.writeFileSync(testFilePath, JSON.stringify(invalidData)); 121 | 122 | expect(() => Persona.loadFromFile(testFilePath)).toThrow( 123 | "Invalid persona data in file" 124 | ); 125 | }); 126 | }); 127 | 128 | describe("render", () => { 129 | let persona: Persona; 130 | 131 | beforeEach(() => { 132 | persona = new Persona( 133 | "Test", 134 | "Description", 135 | "{{ greeting }} {{ name }}!", 136 | 1 137 | ); 138 | }); 139 | 140 | it("should replace single variable", () => { 141 | const result = persona.render({ greeting: "Hello" }); 142 | expect(result).toBe("Hello {{ name }}!"); 143 | }); 144 | 145 | it("should replace multiple variables", () => { 146 | const result = persona.render({ greeting: "Hi", name: "Alice" }); 147 | expect(result).toBe("Hi Alice!"); 148 | }); 149 | 150 | it("should handle case insensitive variable names", () => { 151 | const result = persona.render({ GREETING: "Hey", NAME: "Bob" }); 152 | expect(result).toBe("Hey Bob!"); 153 | }); 154 | 155 | it("should leave unmatched variables as-is", () => { 156 | const result = persona.render({ name: "Charlie" }); 157 | expect(result).toBe("{{ greeting }} Charlie!"); 158 | }); 159 | 160 | it("should handle complex templates", () => { 161 | const complexPersona = new Persona( 162 | "Test", 163 | "Description", 164 | "{{ header }}\n{{ body }}\n{{ footer }}", 165 | 1 166 | ); 167 | const result = complexPersona.render({ 168 | header: "Welcome", 169 | body: "This is the content", 170 | footer: "Goodbye", 171 | }); 172 | expect(result).toBe("Welcome\nThis is the content\nGoodbye"); 173 | }); 174 | }); 175 | 176 | describe("requiredComponents", () => { 177 | it("should return single variable name", () => { 178 | const persona = new Persona("Test", "Description", "{{ name }}", 1); 179 | expect(persona.requiredComponents()).toEqual(["name"]); 180 | }); 181 | 182 | it("should return multiple variable names", () => { 183 | const persona = new Persona( 184 | "Test", 185 | "Description", 186 | "{{ greeting }} {{ name }}!", 187 | 1 188 | ); 189 | expect(persona.requiredComponents()).toEqual(["greeting", "name"]); 190 | }); 191 | 192 | it("should return variable names case insensitive", () => { 193 | const persona = new Persona( 194 | "Test", 195 | "Description", 196 | "{{ GREETING }} {{ NAME }}", 197 | 1 198 | ); 199 | expect(persona.requiredComponents()).toEqual(["greeting", "name"]); 200 | }); 201 | 202 | it("should handle malformed templates", () => { 203 | const persona = new Persona("Test", "Description", "{{ {greeting} }}", 1); 204 | expect(persona.requiredComponents()).toEqual([]); 205 | }); 206 | }); 207 | 208 | describe("equals", () => { 209 | let persona: Persona; 210 | 211 | beforeEach(() => { 212 | persona = new Persona("Test", "Description", "Template", 1); 213 | }); 214 | 215 | it("should return true for identical personas", () => { 216 | const other = new Persona("Test", "Description", "Template", 1); 217 | expect(persona.equals(other)).toBe(true); 218 | }); 219 | 220 | it("should return false for different names", () => { 221 | const other = new Persona("Different", "Description", "Template", 1); 222 | expect(persona.equals(other)).toBe(false); 223 | }); 224 | 225 | it("should return false for different descriptions", () => { 226 | const other = new Persona("Test", "Different", "Template", 1); 227 | expect(persona.equals(other)).toBe(false); 228 | }); 229 | 230 | it("should return false for different template", () => { 231 | const other = new Persona("Test", "Description", "Different", 1); 232 | expect(persona.equals(other)).toBe(false); 233 | }); 234 | 235 | it("should return false for different versions", () => { 236 | const other = new Persona("Test", "Description", "Template", 2); 237 | expect(persona.equals(other)).toBe(false); 238 | }); 239 | 240 | it("should return false when comparing with null", () => { 241 | expect(persona.equals(null)).toBe(false); 242 | }); 243 | 244 | it("should return false when comparing with undefined", () => { 245 | expect(persona.equals(undefined)).toBe(false); 246 | }); 247 | }); 248 | }); 249 | -------------------------------------------------------------------------------- /test/server.test.ts: -------------------------------------------------------------------------------- 1 | import { createServer } from "@src/server.js"; 2 | import { 3 | ListToolsRequestSchema, 4 | JSONRPCMessage 5 | } from "@modelcontextprotocol/sdk/types.js"; 6 | import { Transport } from "@modelcontextprotocol/sdk/shared/transport.js"; 7 | import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; 8 | import { Client } from "@modelcontextprotocol/sdk/client/index.js"; 9 | import { ComponentPersonaService } from "@src/service.js"; 10 | 11 | import fs from "fs"; 12 | 13 | describe("Server Tools", () => { 14 | let server: ReturnType; 15 | let transport: ReturnType; 16 | let tempDir: string; 17 | 18 | beforeEach(() => { 19 | tempDir = fs.mkdtempSync("persona-test-"); 20 | server = createServer(); 21 | transport = InMemoryTransport.createLinkedPair(); 22 | }); 23 | 24 | test("should list all tools successfully", async () => { 25 | const [clientTransport, serverTransport] = transport; 26 | 27 | // Connect server 28 | await server.server.connect(serverTransport); 29 | 30 | // Create client 31 | const client = new Client( 32 | { 33 | name: "test-client", 34 | version: "1.0.0" 35 | }, 36 | { 37 | capabilities: { 38 | tools: {} 39 | } 40 | } 41 | ); 42 | 43 | await client.connect(clientTransport); 44 | 45 | // Get tools 46 | const tools = await client.listTools(); 47 | 48 | // Verify tools list 49 | expect(tools.tools).toHaveLength(8); 50 | expect(tools.tools).toEqual( 51 | expect.arrayContaining([ 52 | expect.objectContaining({ 53 | name: "listPersonas", 54 | description: "List all available personas", 55 | inputSchema: expect.any(Object) 56 | }), 57 | expect.objectContaining({ 58 | name: "listComponents", 59 | description: "List all available components", 60 | inputSchema: expect.any(Object) 61 | }), 62 | expect.objectContaining({ 63 | name: "createOrUpdatePersona", 64 | description: "Create or update a persona", 65 | inputSchema: expect.any(Object) 66 | }), 67 | expect.objectContaining({ 68 | name: "createOrUpdateComponent", 69 | description: "Create or update a component", 70 | inputSchema: expect.any(Object) 71 | }), 72 | expect.objectContaining({ 73 | name: "deletePersona", 74 | description: "Delete a persona", 75 | inputSchema: expect.any(Object) 76 | }), 77 | expect.objectContaining({ 78 | name: "deleteComponent", 79 | description: "Delete a component", 80 | inputSchema: expect.any(Object) 81 | }), 82 | expect.objectContaining({ 83 | name: "activatePersona", 84 | description: "Activate a specific persona", 85 | inputSchema: expect.any(Object) 86 | }), 87 | expect.objectContaining({ 88 | name: "getActivePersona", 89 | description: "Get the currently active persona", 90 | inputSchema: expect.any(Object) 91 | }) 92 | ]) 93 | ); 94 | }); 95 | 96 | afterEach(() => { 97 | // Clean up temporary directory 98 | const { service } = server; 99 | fs.rmSync(tempDir, { recursive: true }); 100 | }); 101 | 102 | test("should list personas successfully", async () => { 103 | const [clientTransport, serverTransport] = transport; 104 | 105 | // Connect server 106 | await server.server.connect(serverTransport); 107 | 108 | // Create client 109 | const client = new Client( 110 | { 111 | name: "test-client", 112 | version: "1.0.0" 113 | }, 114 | { 115 | capabilities: { 116 | tools: {} 117 | } 118 | } 119 | ); 120 | 121 | await client.connect(clientTransport); 122 | 123 | // Create test persona 124 | const { service } = server; 125 | service.setPersona(tempDir, "test-persona", "test description", "template", 1); 126 | 127 | // Get personas 128 | const response = await client.callTool({ 129 | name: "listPersonas", 130 | arguments: { projectRoot: tempDir } 131 | }) as { content: Array<{ type: string; text: string }> }; 132 | 133 | // Verify response 134 | expect(response.content[0].text).toBeDefined(); 135 | const personas = JSON.parse(response.content[0].text); 136 | expect(personas).toEqual(expect.arrayContaining(["test-persona"])); 137 | }); 138 | 139 | test("should list components successfully", async () => { 140 | const [clientTransport, serverTransport] = transport; 141 | 142 | // Connect server 143 | await server.server.connect(serverTransport); 144 | 145 | // Create client 146 | const client = new Client( 147 | { 148 | name: "test-client", 149 | version: "1.0.0" 150 | }, 151 | { 152 | capabilities: { 153 | tools: {} 154 | } 155 | } 156 | ); 157 | 158 | await client.connect(clientTransport); 159 | 160 | // Create test component 161 | const { service } = server; 162 | service.setComponent(tempDir, "test-component", "test description", "text", 1); 163 | 164 | // Get components 165 | const response = await client.callTool({ 166 | name: "listComponents", 167 | arguments: { projectRoot: tempDir } 168 | }) as { content: Array<{ type: string; text: string }> }; 169 | 170 | // Verify response 171 | expect(response.content[0].text).toBeDefined(); 172 | const components = JSON.parse(response.content[0].text); 173 | expect(components).toEqual(expect.arrayContaining(["test-component"])); 174 | }); 175 | 176 | test("should create or update persona successfully", async () => { 177 | const [clientTransport, serverTransport] = transport; 178 | 179 | // Connect server 180 | await server.server.connect(serverTransport); 181 | 182 | // Create client 183 | const client = new Client( 184 | { 185 | name: "test-client", 186 | version: "1.0.0" 187 | }, 188 | { 189 | capabilities: { 190 | tools: {} 191 | } 192 | } 193 | ); 194 | 195 | await client.connect(clientTransport); 196 | 197 | // Create persona 198 | const response = await client.callTool({ 199 | name: "createOrUpdatePersona", 200 | arguments: { 201 | projectRoot: tempDir, 202 | name: "test-persona", 203 | description: "test description", 204 | template: "template", 205 | version: 1 206 | } 207 | }) as { content: Array<{ type: string; text: string }> }; 208 | 209 | // Verify response 210 | expect(response.content[0].text).toBeDefined(); 211 | const result = JSON.parse(response.content[0].text); 212 | expect(result.success).toBe(true); 213 | 214 | // Verify persona was created 215 | const { service } = server; 216 | const persona = service.getPersona(tempDir, "test-persona"); 217 | expect(persona).toBeDefined(); 218 | expect(persona?.description).toBe("test description"); 219 | }); 220 | 221 | test("should create or update component successfully", async () => { 222 | const [clientTransport, serverTransport] = transport; 223 | 224 | // Connect server 225 | await server.server.connect(serverTransport); 226 | 227 | // Create client 228 | const client = new Client( 229 | { 230 | name: "test-client", 231 | version: "1.0.0" 232 | }, 233 | { 234 | capabilities: { 235 | tools: {} 236 | } 237 | } 238 | ); 239 | 240 | await client.connect(clientTransport); 241 | 242 | // Create component 243 | const response = await client.callTool({ 244 | name: "createOrUpdateComponent", 245 | arguments: { 246 | projectRoot: tempDir, 247 | name: "test-component", 248 | description: "test description", 249 | text: "test text", 250 | version: 1 251 | } 252 | }) as { content: Array<{ type: string; text: string }> }; 253 | 254 | // Verify response 255 | expect(response.content[0].text).toBeDefined(); 256 | const result = JSON.parse(response.content[0].text); 257 | expect(result.success).toBe(true); 258 | 259 | // Verify component was created 260 | const { service } = server; 261 | const component = service.getComponent(tempDir, "test-component"); 262 | expect(component).toBeDefined(); 263 | expect(component?.description).toBe("test description"); 264 | expect(component?.text).toBe("test text"); 265 | }); 266 | 267 | test("should delete persona successfully", async () => { 268 | const [clientTransport, serverTransport] = transport; 269 | 270 | // Connect server 271 | await server.server.connect(serverTransport); 272 | 273 | // Create client 274 | const client = new Client( 275 | { 276 | name: "test-client", 277 | version: "1.0.0" 278 | }, 279 | { 280 | capabilities: { 281 | tools: {} 282 | } 283 | } 284 | ); 285 | 286 | await client.connect(clientTransport); 287 | 288 | // Create test persona 289 | const { service } = server; 290 | service.setPersona(tempDir, "test-persona", "test description", "template", 1); 291 | 292 | // Delete persona 293 | const response = await client.callTool({ 294 | name: "deletePersona", 295 | arguments: { 296 | projectRoot: tempDir, 297 | name: "test-persona" 298 | } 299 | }) as { content: Array<{ type: string; text: string }> }; 300 | 301 | // Verify response 302 | expect(response.content[0].text).toBeDefined(); 303 | const result = JSON.parse(response.content[0].text); 304 | expect(result.success).toBe(true); 305 | 306 | // Verify persona was deleted 307 | const deletedPersona = service.getPersona(tempDir, "test-persona"); 308 | expect(deletedPersona).toBeNull(); 309 | }); 310 | 311 | test("should delete component successfully", async () => { 312 | const [clientTransport, serverTransport] = transport; 313 | 314 | // Connect server 315 | await server.server.connect(serverTransport); 316 | 317 | // Create client 318 | const client = new Client( 319 | { 320 | name: "test-client", 321 | version: "1.0.0" 322 | }, 323 | { 324 | capabilities: { 325 | tools: {} 326 | } 327 | } 328 | ); 329 | 330 | await client.connect(clientTransport); 331 | 332 | // Create test component 333 | const { service } = server; 334 | service.setComponent(tempDir, "test-component", "test description", "test text", 1); 335 | 336 | // Delete component 337 | const response = await client.callTool({ 338 | name: "deleteComponent", 339 | arguments: { 340 | projectRoot: tempDir, 341 | name: "test-component" 342 | } 343 | }) as { content: Array<{ type: string; text: string }> }; 344 | 345 | // Verify response 346 | expect(response.content[0].text).toBeDefined(); 347 | const result = JSON.parse(response.content[0].text); 348 | expect(result.success).toBe(true); 349 | 350 | // Verify component was deleted 351 | const deletedComponent = service.getComponent(tempDir, "test-component"); 352 | expect(deletedComponent).toBeNull(); 353 | }); 354 | 355 | test("should activate persona successfully", async () => { 356 | const [clientTransport, serverTransport] = transport; 357 | 358 | // Connect server 359 | await server.server.connect(serverTransport); 360 | 361 | // Create client 362 | const client = new Client( 363 | { 364 | name: "test-client", 365 | version: "1.0.0" 366 | }, 367 | { 368 | capabilities: { 369 | tools: {} 370 | } 371 | } 372 | ); 373 | 374 | await client.connect(clientTransport); 375 | 376 | // Create test persona 377 | const { service } = server; 378 | service.setPersona(tempDir, "test-persona", "test description", "template", 1); 379 | 380 | // Activate persona 381 | const response = await client.callTool({ 382 | name: "activatePersona", 383 | arguments: { 384 | projectRoot: tempDir, 385 | name: "test-persona" 386 | } 387 | }) as { content: Array<{ type: string; text: string }> }; 388 | 389 | // Verify response 390 | expect(response.content[0].text).toBeDefined(); 391 | const result = JSON.parse(response.content[0].text); 392 | expect(result.success).toBe(true); 393 | 394 | // Verify persona was activated 395 | const activePersona = service.getActivePersona(tempDir); 396 | expect(activePersona).toBe("test-persona"); 397 | }); 398 | 399 | test("should get active persona successfully", async () => { 400 | const [clientTransport, serverTransport] = transport; 401 | 402 | // Connect server 403 | await server.server.connect(serverTransport); 404 | 405 | // Create client 406 | const client = new Client( 407 | { 408 | name: "test-client", 409 | version: "1.0.0" 410 | }, 411 | { 412 | capabilities: { 413 | tools: {} 414 | } 415 | } 416 | ); 417 | 418 | await client.connect(clientTransport); 419 | 420 | // Create and activate test persona 421 | const { service } = server; 422 | service.setPersona(tempDir, "test-persona", "test description", "template", 1); 423 | service.activatePersona(tempDir, "test-persona"); 424 | 425 | // Get active persona 426 | const response = await client.callTool({ 427 | name: "getActivePersona", 428 | arguments: { projectRoot: tempDir } 429 | }) as { content: Array<{ type: string; text: string }> }; 430 | 431 | // Verify response 432 | expect(response.content[0].text).toBeDefined(); 433 | expect(response.content[0].text).toBe("test-persona"); 434 | }); 435 | 436 | // Should return null if no active persona 437 | test("should return null if no active persona", async () => { 438 | const [clientTransport, serverTransport] = transport; 439 | 440 | // Connect server 441 | await server.server.connect(serverTransport); 442 | 443 | // Create client 444 | const client = new Client( 445 | { 446 | name: "test-client", 447 | version: "1.0.0" 448 | }, 449 | { 450 | capabilities: { 451 | tools: {} 452 | } 453 | } 454 | ); 455 | 456 | await client.connect(clientTransport); 457 | 458 | // Get active persona 459 | const response = await client.callTool({ 460 | name: "getActivePersona", 461 | arguments: { projectRoot: tempDir } 462 | }) as { content: Array<{ type: string; text: string }> }; 463 | 464 | // Verify response 465 | expect(response.content).toHaveLength(0); 466 | }); 467 | 468 | // Should throw an error requesting a non-existent tool 469 | test("should throw error for non-existent tool", async () => { 470 | const [clientTransport, serverTransport] = transport; 471 | 472 | // Connect server 473 | await server.server.connect(serverTransport); 474 | 475 | // Create client 476 | const client = new Client( 477 | { 478 | name: "test-client", 479 | version: "1.0.0" 480 | }, 481 | { 482 | capabilities: { 483 | tools: {} 484 | } 485 | } 486 | ); 487 | 488 | await client.connect(clientTransport); 489 | 490 | // Call non-existent tool 491 | await expect(client.callTool({ 492 | name: "nonExistentTool", 493 | arguments: { tempDir } 494 | })).rejects.toThrow("Unknown tool: nonExistentTool"); 495 | }); 496 | }); 497 | -------------------------------------------------------------------------------- /test/service.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import path, { dirname } from "path"; 3 | import { describe, it, beforeEach, afterEach } from "@jest/globals"; 4 | import { ComponentPersonaService } from "@src/service.js"; 5 | import { Component } from "@src/component.js"; 6 | import { Persona } from "@src/persona.js"; 7 | import { fileURLToPath } from "url"; 8 | import { jest } from "@jest/globals"; 9 | 10 | const __filename = fileURLToPath(import.meta.url); 11 | const __dirname = dirname(__filename); 12 | 13 | describe("ComponentPersonaService", () => { 14 | let tempDir: string; 15 | let service: ComponentPersonaService; 16 | 17 | beforeEach(() => { 18 | tempDir = fs.mkdtempSync("component-persona-service-test-"); 19 | console.log("Created temp dir:", tempDir); 20 | service = new ComponentPersonaService(); 21 | }); 22 | 23 | afterEach(() => { 24 | fs.rmSync(tempDir, { recursive: true, force: true }); 25 | }); 26 | 27 | describe("Component operations", () => { 28 | it("should set and get a component", () => { 29 | service.setComponent(tempDir, "test", "description", "text", 1); 30 | const component = service.getComponent(tempDir, "test"); 31 | expect(component).toBeInstanceOf(Component); 32 | expect(component?.name).toBe("test"); 33 | expect(component?.description).toBe("description"); 34 | expect(component?.text).toBe("text"); 35 | expect(component?.version).toBe(1); 36 | }); 37 | 38 | it("should return null for non-existent component", () => { 39 | const component = service.getComponent(tempDir, "nonexistent"); 40 | expect(component).toBeNull(); 41 | }); 42 | 43 | it("should list components", () => { 44 | service.setComponent(tempDir, "test1", "desc1", "text1", 1); 45 | service.setComponent(tempDir, "test2", "desc2", "text2", 1); 46 | const components = service.listComponents(tempDir); 47 | expect(components).toEqual(["test1", "test2"]); 48 | }); 49 | 50 | it("should delete a component", () => { 51 | service.setComponent(tempDir, "test", "description", "text", 1); 52 | service.deleteComponent(tempDir, "test"); 53 | const component = service.getComponent(tempDir, "test"); 54 | expect(component).toBeNull(); 55 | }); 56 | 57 | it("should handle idempotent delete", () => { 58 | service.deleteComponent(tempDir, "nonexistent"); // Should not throw 59 | }); 60 | }); 61 | 62 | describe("Persona operations", () => { 63 | it("should set and get a persona", () => { 64 | service.setPersona(tempDir, "test", "description", "template", 1); 65 | const persona = service.getPersona(tempDir, "test"); 66 | expect(persona).toBeInstanceOf(Persona); 67 | expect(persona?.name).toBe("test"); 68 | expect(persona?.description).toBe("description"); 69 | expect(persona?.template).toBe("template"); 70 | expect(persona?.version).toBe(1); 71 | }); 72 | 73 | it("should return null for non-existent persona", () => { 74 | const persona = service.getPersona(tempDir, "nonexistent"); 75 | expect(persona).toBeNull(); 76 | }); 77 | 78 | it("should list personas", () => { 79 | service.setPersona(tempDir, "test1", "desc1", "template1", 1); 80 | service.setPersona(tempDir, "test2", "desc2", "template2", 1); 81 | const personas = service.listPersonas(tempDir); 82 | expect(personas).toEqual(["test1", "test2"]); 83 | }); 84 | 85 | it("should delete a persona", () => { 86 | service.setPersona(tempDir, "test", "description", "template", 1); 87 | service.deletePersona(tempDir, "test"); 88 | const persona = service.getPersona(tempDir, "test"); 89 | expect(persona).toBeNull(); 90 | }); 91 | 92 | it("should handle idempotent delete", () => { 93 | service.deletePersona(tempDir, "nonexistent"); // Should not throw 94 | }); 95 | }); 96 | 97 | describe("Component deletion validation", () => { 98 | it("should prevent deleting a component when personas depend on it", () => { 99 | // Create component and persona that depends on it 100 | service.setComponent(tempDir, "comp1", "desc", "text", 1); 101 | service.setPersona(tempDir, "persona1", "desc", "template with {{comp1}}", 1); 102 | 103 | expect(() => service.deleteComponent(tempDir, "comp1")).toThrow( 104 | /Cannot delete component: required by personas:.*persona1/ 105 | ); 106 | }); 107 | 108 | it("should allow deleting a component when no personas depend on it", () => { 109 | service.setComponent(tempDir, "comp1", "desc", "text", 1); 110 | service.deleteComponent(tempDir, "comp1"); 111 | expect(service.getComponent(tempDir, "comp1")).toBeNull(); 112 | }); 113 | }); 114 | 115 | describe("Persona validation", () => { 116 | it("should prevent saving persona with non-existent component dependencies", () => { 117 | expect(() => 118 | service.setPersona( 119 | tempDir, 120 | "persona1", 121 | "desc", 122 | "template with {{nonexistent}}", 123 | 1 124 | ) 125 | ).toThrow( 126 | "Cannot save persona: depends on non-existent component: nonexistent" 127 | ); 128 | }); 129 | 130 | it("should allow saving persona when all dependencies exist", () => { 131 | service.setComponent(tempDir, "comp1", "desc", "text", 1); 132 | expect(() => 133 | service.setPersona(tempDir, "persona1", "desc", "template with {{comp1}}", 1) 134 | ).not.toThrow(); 135 | }); 136 | }); 137 | 138 | describe("Persona activation", () => { 139 | it("should write persona template to .clinerules file", () => { 140 | service.setPersona(tempDir, "persona1", "desc", "template content", 1); 141 | service.activatePersona(tempDir, "persona1"); 142 | let clineRulesPath = path.join(tempDir, ".clinerules"); 143 | expect(fs.existsSync(clineRulesPath)).toBeTruthy(); 144 | expect(fs.readFileSync(clineRulesPath, "utf-8")).toBe("template content"); 145 | }); 146 | 147 | it("should throw when activating non-existent persona", () => { 148 | expect(() => service.activatePersona(tempDir, "nonexistent")).toThrow( 149 | "Persona not found: nonexistent" 150 | ); 151 | }); 152 | 153 | it("should get active persona name from .clinerules file", () => { 154 | service.setPersona(tempDir, "persona1", "desc", "template content", 1); 155 | service.activatePersona(tempDir, "persona1"); 156 | 157 | const activePersona = service.getActivePersona(tempDir); 158 | expect(activePersona).toBe("persona1"); 159 | }); 160 | 161 | it("should return null when no persona is active", () => { 162 | expect(service.getActivePersona(tempDir)).toBeNull(); 163 | }); 164 | 165 | it("should return null when .clinerules file is empty", () => { 166 | fs.writeFileSync(path.join(tempDir, ".clinerules"), ""); 167 | expect(service.getActivePersona(tempDir)).toBeNull(); 168 | }); 169 | }); 170 | 171 | describe("Directory handling", () => { 172 | it("should create component directory if not exists", () => { 173 | const dir = path.join(tempDir, "new-components"); 174 | const newService = new ComponentPersonaService(); 175 | newService.listComponents(dir); 176 | expect(fs.existsSync(dir)).toBeTruthy(); 177 | }); 178 | 179 | it("should create persona directory if not exists", () => { 180 | const dir = path.join(tempDir, "new-personas"); 181 | const newService = new ComponentPersonaService(); 182 | newService.listPersonas(dir); 183 | expect(fs.existsSync(dir)).toBeTruthy(); 184 | }); 185 | }); 186 | 187 | describe("renderPersona", () => { 188 | it("should render persona with component texts", () => { 189 | // Setup components 190 | service.setComponent(tempDir, "comp1", "desc1", "text1", 1); 191 | service.setComponent(tempDir, "comp2", "desc2", "text2", 1); 192 | 193 | // Setup persona with template 194 | const template = "Component 1: {{comp1}}\nComponent 2: {{comp2}}"; 195 | service.setPersona(tempDir, "test", "description", template, 1); 196 | 197 | // Render persona 198 | const result = service.renderPersona(tempDir, "test"); 199 | 200 | // Verify output 201 | expect(result).toBe("Component 1: text1\nComponent 2: text2"); 202 | }); 203 | 204 | it("should throw when persona does not exist", () => { 205 | expect(() => service.renderPersona(tempDir, "nonexistent")).toThrow( 206 | "Persona not found: nonexistent" 207 | ); 208 | }); 209 | 210 | it("should handle missing components in template", () => { 211 | // Create a component that isn't referenced by any persona 212 | service.setComponent(tempDir, "unusedComp", "desc", "text", 1); 213 | 214 | // Create persona that depends on a different component 215 | service.setPersona( 216 | tempDir, 217 | "test", 218 | "description", 219 | "Template with {{unusedComp}}", 220 | 1 221 | ); 222 | 223 | // Delete the unused component to simulate it being missing 224 | service.deleteComponent(tempDir, "unusedComp"); 225 | 226 | // Verify error is thrown when trying to render 227 | expect(() => service.renderPersona(tempDir, "test")).toThrow( 228 | "Cannot render persona: missing required component: unusedcomp" 229 | ); 230 | }); 231 | }); 232 | 233 | describe("describePersonas", () => { 234 | it("should return empty map when no personas exist", () => { 235 | const result = service.describePersonas(tempDir); 236 | expect(result.size).toBe(0); 237 | }); 238 | 239 | it("should return correct name-description mappings", () => { 240 | service.setPersona(tempDir, "persona1", "description1", "template1", 1); 241 | service.setPersona(tempDir, "persona2", "description2", "template2", 1); 242 | 243 | const result = service.describePersonas(tempDir); 244 | expect(result.size).toBe(2); 245 | expect(result.get("persona1")).toBe("description1"); 246 | expect(result.get("persona2")).toBe("description2"); 247 | }); 248 | }); 249 | 250 | describe("describeComponents", () => { 251 | it("should return empty map when no components exist", () => { 252 | const result = service.describeComponents(tempDir); 253 | expect(result.size).toBe(0); 254 | }); 255 | 256 | it("should return correct name-description mappings", () => { 257 | service.setComponent(tempDir, "comp1", "description1", "text1", 1); 258 | service.setComponent(tempDir, "comp2", "description2", "text2", 1); 259 | 260 | const result = service.describeComponents(tempDir); 261 | expect(result.size).toBe(2); 262 | expect(result.get("comp1")).toBe("description1"); 263 | expect(result.get("comp2")).toBe("description2"); 264 | }); 265 | }); 266 | }); 267 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "module": "Node16", 5 | "moduleResolution": "Node16", 6 | "outDir": "./dist", 7 | "rootDir": ".", 8 | "strict": true, 9 | "allowJs": true, 10 | "esModuleInterop": true, 11 | "skipLibCheck": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "paths": { 14 | "@src/*": ["./src/*"], 15 | "@test/*": ["./test/*"] 16 | } 17 | }, 18 | "include": ["./src/**/*", "./test/**/*", "./jest.config.ts"], 19 | "exclude": ["node_modules"] 20 | } 21 | --------------------------------------------------------------------------------