kalo-cli 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -33,6 +33,27 @@ bun add -g kalo-cli
33
33
  kalo
34
34
  ```
35
35
 
36
+ ### Configuration
37
+
38
+ Vous pouvez personnaliser le comportement de Kalo en créant un fichier `kalo.config.json` ou `kalo.config.js` à la racine de votre projet.
39
+
40
+ Exemple `kalo.config.json` :
41
+
42
+ ```json
43
+ {
44
+ "appDir": "apps",
45
+ "aiProvider": "Qwen",
46
+ "temperature": 0.5,
47
+ "verbosity": "standard"
48
+ }
49
+ ```
50
+
51
+ Options disponibles :
52
+ - `appDir` : Le dossier où se trouvent vos applications Django (par défaut: `app`).
53
+ - `aiProvider` : Le fournisseur d'IA par défaut (`Qwen`, `Ollama`, `Mistral`, `OpenAI`).
54
+ - `temperature` : La créativité de l'IA (entre 0 et 1).
55
+ - `verbosity` : Le niveau de détail des commentaires générés (`minimal`, `standard`, `verbose`).
56
+
36
57
  ### Si installé dans un projet
37
58
 
38
59
  Utilisez `bun run` ou `bunx` :
package/bin/kalo.ts CHANGED
@@ -1,17 +1,53 @@
1
1
  #!/usr/bin/env bun
2
2
  import path from "node:path";
3
+ import fs from "node:fs";
3
4
  import minimist from "minimist";
4
5
  import { Plop, run } from "plop";
5
6
  import { fileURLToPath } from "node:url";
7
+ import { loadConfig } from "../generators/utils/config";
6
8
 
7
9
  const args = process.argv.slice(2);
8
10
  const argv = minimist(args);
9
11
 
10
12
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
13
 
14
+ /**
15
+ * Recherche le répertoire racine du projet en remontant à partir d'un dossier donné.
16
+ */
17
+ function findProjectRoot(startDir: string): string {
18
+ let currentDir = startDir;
19
+ while (currentDir !== path.parse(currentDir).root) {
20
+ const packageJsonPath = path.join(currentDir, 'package.json');
21
+ if (fs.existsSync(packageJsonPath)) {
22
+ try {
23
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
24
+ if (pkg.name !== 'kalo-cli' || currentDir.includes('node_modules')) {
25
+ if (!currentDir.includes('node_modules')) {
26
+ return currentDir;
27
+ }
28
+ } else {
29
+ return currentDir;
30
+ }
31
+ } catch (e) {}
32
+ }
33
+ currentDir = path.dirname(currentDir);
34
+ }
35
+ return startDir;
36
+ }
37
+
38
+ const projectRoot = findProjectRoot(process.cwd());
39
+ const config = await loadConfig();
40
+
12
41
  Plop.prepare({
13
- cwd: argv.cwd,
42
+ cwd: argv.cwd || projectRoot,
14
43
  configPath: path.join(__dirname, '../plopfile.ts'),
15
44
  preload: argv.require || [],
16
45
  completion: argv.completion
17
- }, env => Plop.execute(env, (env) => run(env, undefined, true)));
46
+ }, env => Plop.execute(env, (env) => {
47
+ const options = {
48
+ ...env,
49
+ dest: argv.dest || env.dest,
50
+ config: config // Passer la config au contexte Plop
51
+ };
52
+ return run(options, undefined, true);
53
+ }));
@@ -144,7 +144,7 @@ export const aiEnhancerGenerator: PlopGeneratorConfig = {
144
144
  name: 'aiProvider',
145
145
  message: 'Select AI Provider:',
146
146
  default: 'Qwen',
147
- when: (answers) => !isBack(answers),
147
+ when: (answers: any) => !isBack(answers) && !answers.config?.aiProvider,
148
148
  source: async (answers, input) => {
149
149
  const choices = AI_PROVIDER_TYPES.map(p => ({ name: p, value: p }));
150
150
  const results = await searchChoices(input, choices);
@@ -158,7 +158,7 @@ export const aiEnhancerGenerator: PlopGeneratorConfig = {
158
158
  name: 'temperature',
159
159
  message: 'Select Creativity Level:',
160
160
  default: 0.5,
161
- when: (answers) => !isBack(answers),
161
+ when: (answers: any) => !isBack(answers) && answers.config?.temperature === undefined,
162
162
  source: async (answers, input) => {
163
163
  const results = await searchChoices(input, AI_TEMP_TYPES);
164
164
  results.push({ name: 'Exit', value: EXIT_VALUE });
@@ -171,7 +171,7 @@ export const aiEnhancerGenerator: PlopGeneratorConfig = {
171
171
  name: 'verbosity',
172
172
  message: 'Select Verbosity Level:',
173
173
  default: 'standard',
174
- when: (answers) => !isBack(answers),
174
+ when: (answers: any) => !isBack(answers) && !answers.config?.verbosity,
175
175
  source: async (answers, input) => {
176
176
  const choices = [
177
177
  { name: 'Minimal (Code only)', value: 'minimal' },
@@ -21,47 +21,50 @@ export const djangoAppGenerator: PlopGeneratorConfig = {
21
21
  },
22
22
  },
23
23
  ],
24
- actions: [
25
- {
26
- type: 'add',
27
- path: 'app/__init__.py',
28
- templateFile: 'generators/django-app/templates/init.py.hbs',
29
- skipIfExists: true,
30
- },
31
- {
32
- type: 'add',
33
- path: 'app/{{name}}/__init__.py',
34
- templateFile: 'generators/django-app/templates/init.py.hbs',
35
- },
36
- {
37
- type: 'add',
38
- path: 'app/{{name}}/apps.py',
39
- templateFile: 'generators/django-app/templates/apps.py.hbs',
40
- },
41
- {
42
- type: 'add',
43
- path: 'app/{{name}}/models/__init__.py',
44
- templateFile: 'generators/django-app/templates/models_init.py.hbs',
45
- },
46
- {
47
- type: 'add',
48
- path: 'app/{{name}}/tests/__init__.py',
49
- templateFile: 'generators/django-app/templates/init.py.hbs',
50
- },
51
- {
52
- type: 'add',
53
- path: 'app/{{name}}/views.py',
54
- templateFile: 'generators/django-app/templates/views.py.hbs',
55
- },
56
- {
57
- type: 'add',
58
- path: 'app/{{name}}/urls.py',
59
- templateFile: 'generators/django-app/templates/urls.py.hbs',
60
- },
61
- {
62
- type: 'add',
63
- path: 'app/{{name}}/admin.py',
64
- templateFile: 'generators/django-app/templates/admin.py.hbs',
65
- },
66
- ],
24
+ actions: (data: any) => {
25
+ const appDir = data.config?.appDir || 'app';
26
+ return [
27
+ {
28
+ type: 'add',
29
+ path: `${appDir}/__init__.py`,
30
+ templateFile: 'generators/django-app/templates/init.py.hbs',
31
+ skipIfExists: true,
32
+ },
33
+ {
34
+ type: 'add',
35
+ path: `${appDir}/{{name}}/__init__.py`,
36
+ templateFile: 'generators/django-app/templates/init.py.hbs',
37
+ },
38
+ {
39
+ type: 'add',
40
+ path: `${appDir}/{{name}}/apps.py`,
41
+ templateFile: 'generators/django-app/templates/apps.py.hbs',
42
+ },
43
+ {
44
+ type: 'add',
45
+ path: `${appDir}/{{name}}/models/__init__.py`,
46
+ templateFile: 'generators/django-app/templates/models_init.py.hbs',
47
+ },
48
+ {
49
+ type: 'add',
50
+ path: `${appDir}/{{name}}/tests/__init__.py`,
51
+ templateFile: 'generators/django-app/templates/init.py.hbs',
52
+ },
53
+ {
54
+ type: 'add',
55
+ path: `${appDir}/{{name}}/views.py`,
56
+ templateFile: 'generators/django-app/templates/views.py.hbs',
57
+ },
58
+ {
59
+ type: 'add',
60
+ path: `${appDir}/{{name}}/urls.py`,
61
+ templateFile: 'generators/django-app/templates/urls.py.hbs',
62
+ },
63
+ {
64
+ type: 'add',
65
+ path: `${appDir}/{{name}}/admin.py`,
66
+ templateFile: 'generators/django-app/templates/admin.py.hbs',
67
+ },
68
+ ];
69
+ },
67
70
  };
@@ -39,9 +39,10 @@ export const djangoChannelGenerator: PlopGeneratorConfig = {
39
39
  message: 'Nom de l\'application cible',
40
40
  },
41
41
  ],
42
- actions: (data) => {
42
+ actions: (data: any) => {
43
43
  const actions = [];
44
- const appPath = `app/${data.app}`;
44
+ const appDir = data.config?.appDir || 'app';
45
+ const appPath = `${appDir}/${data.app}`;
45
46
  const isAsync = data.type === 'async';
46
47
  data.isAsync = isAsync;
47
48
 
@@ -38,15 +38,16 @@ export const djangoFormGenerator: PlopGeneratorConfig = {
38
38
  message: 'Nom de l\'application cible',
39
39
  },
40
40
  ],
41
- actions: (data) => {
41
+ actions: (data: any) => {
42
42
  const actions = [];
43
- const appPath = `app/${data.app}`;
43
+ const appDir = data.config?.appDir || 'app';
44
+ const appPath = `${appDir}/${data.app}`;
44
45
  const isModelForm = data.type === 'model_form';
45
46
 
46
47
  // Ensure suffix
47
48
  data.name = ensureSuffix(data.name, 'Form');
48
49
 
49
- const templateFile = isModelForm
50
+ const templateFile = isModelForm
50
51
  ? 'generators/django-form/templates/model_form.py.hbs'
51
52
  : 'generators/django-form/templates/form.py.hbs';
52
53
 
@@ -34,9 +34,10 @@ export const djangoViewGenerator: PlopGeneratorConfig = {
34
34
  message: 'Nom de l\'application cible',
35
35
  },
36
36
  ],
37
- actions: (data) => {
37
+ actions: (data: any) => {
38
38
  const actions = [];
39
- const appPath = `app/${data.app}`;
39
+ const appDir = data.config?.appDir || 'app';
40
+ const appPath = `${appDir}/${data.app}`;
40
41
  const isCBV = data.type !== 'fbv';
41
42
 
42
43
  // Helper to ensure correct suffix for CBV
@@ -11,15 +11,15 @@ import keywordConfig from '../ai-enhancer/keywords.json';
11
11
  import { getAppList, getAppFiles } from '../utils/filesystem';
12
12
  import { analyzeAppFile, analyzeFile } from '../utils/analysis';
13
13
  import { searchChoices } from '../utils/search';
14
- import {
15
- MAIN_COMPONENT_TYPES,
16
- VIEW_TYPES,
17
- FORM_TYPES,
18
- ADMIN_EXT_TYPES,
19
- CHANNEL_TYPES,
20
- AI_PROVIDER_TYPES,
14
+ import {
15
+ MAIN_COMPONENT_TYPES,
16
+ VIEW_TYPES,
17
+ FORM_TYPES,
18
+ ADMIN_EXT_TYPES,
19
+ CHANNEL_TYPES,
20
+ AI_PROVIDER_TYPES,
21
21
  AI_TEMP_TYPES,
22
- EXIT_VALUE
22
+ EXIT_VALUE
23
23
  } from '../constants';
24
24
 
25
25
  /**
@@ -143,19 +143,19 @@ const mainGenerator: PlopGeneratorConfig = {
143
143
  name: 'selectedApp',
144
144
  message: 'Sélectionnez l\'application cible :',
145
145
  when: (answers) => ['add', 'modify'].includes(answers.mode),
146
- source: (answers, input) => {
147
- const apps = getAppList();
146
+ source: (answers: any, input) => {
147
+ const apps = getAppList(answers.config);
148
148
  if (apps.length === 0) {
149
149
  return Promise.resolve([{ name: 'Aucune application trouvée', value: null }]);
150
150
  }
151
151
  const appChoices = apps.map(app => ({ name: app, value: app }));
152
152
  return searchChoices(input, appChoices);
153
153
  },
154
- filter: (input) => {
154
+ filter: (input: string, answers: any) => {
155
155
  if (!input) return null;
156
156
  // Only analyze default models.py if we are NOT in modify mode (or as fallback)
157
157
  // But for 'add' mode, we might want it. For 'modify', we will select file explicitly.
158
- const info = analyzeAppFile(input);
158
+ const info = analyzeAppFile(input, answers?.config);
159
159
  return { name: input, info };
160
160
  }
161
161
  },
@@ -232,9 +232,9 @@ const mainGenerator: PlopGeneratorConfig = {
232
232
  name: 'selectedFile',
233
233
  message: 'Sélectionnez le fichier à modifier :',
234
234
  when: (answers) => answers.mode === 'modify' && answers.selectedApp !== null,
235
- source: (answers, input) => {
235
+ source: (answers: any, input) => {
236
236
  const appName = answers.selectedApp.name || answers.selectedApp;
237
- const files = getAppFiles(appName);
237
+ const files = getAppFiles(appName, answers.config);
238
238
 
239
239
  const choices = [];
240
240
  if (files.length > 0) {
@@ -254,11 +254,11 @@ const mainGenerator: PlopGeneratorConfig = {
254
254
  name: 'targetClass',
255
255
  message: 'Sélectionnez la classe à modifier :',
256
256
  when: (answers) => answers.mode === 'modify' && answers.selectedFile !== null && answers.selectedFile !== 'help',
257
- source: (answers, input) => {
257
+ source: (answers: any, input) => {
258
258
  const appName = answers.selectedApp.name || answers.selectedApp;
259
259
  const filePath = answers.selectedFile;
260
260
 
261
- const info = analyzeFile(appName, filePath);
261
+ const info = analyzeFile(appName, filePath, answers.config);
262
262
  if (!info || !info.classes || info.classes.length === 0) {
263
263
  return Promise.resolve([{ name: 'Aucune classe trouvée (ou fichier vide)', value: null }]);
264
264
  }
@@ -277,29 +277,29 @@ const mainGenerator: PlopGeneratorConfig = {
277
277
  type: 'list',
278
278
  name: 'aiProvider',
279
279
  message: 'Sélectionnez le fournisseur IA :',
280
- when: (answers) => answers.mode === 'modify' && answers.targetClass !== null && answers.selectedFile !== 'help',
280
+ when: (answers) => answers.mode === 'modify' && answers.targetClass !== null && answers.selectedFile !== 'help' && !answers.config?.aiProvider,
281
281
  choices: AI_PROVIDER_TYPES,
282
- default: 'Qwen'
282
+ default: (answers: any) => answers.config?.aiProvider || 'Qwen'
283
283
  },
284
284
  {
285
285
  type: 'list',
286
286
  name: 'temperature',
287
287
  message: 'Niveau de créativité :',
288
- when: (answers) => answers.mode === 'modify' && answers.targetClass !== null && answers.selectedFile !== 'help',
288
+ when: (answers) => answers.mode === 'modify' && answers.targetClass !== null && answers.selectedFile !== 'help' && answers.config?.temperature === undefined,
289
289
  choices: AI_TEMP_TYPES,
290
- default: 0.5
290
+ default: (answers: any) => answers.config?.temperature !== undefined ? answers.config.temperature : 0.5
291
291
  },
292
292
  {
293
293
  type: 'list',
294
294
  name: 'verbosity',
295
295
  message: 'Niveau de verbosité :',
296
- when: (answers) => answers.mode === 'modify' && answers.targetClass !== null && answers.selectedFile !== 'help',
296
+ when: (answers) => answers.mode === 'modify' && answers.targetClass !== null && answers.selectedFile !== 'help' && !answers.config?.verbosity,
297
297
  choices: [
298
298
  { name: 'Minimal (Code seulement)', value: 'minimal' },
299
299
  { name: 'Standard (Code + commentaires basiques)', value: 'standard' },
300
300
  { name: 'Verbeux (Documentation détaillée)', value: 'verbose' }
301
301
  ],
302
- default: 'standard'
302
+ default: (answers: any) => answers.config?.verbosity || 'standard'
303
303
  },
304
304
  {
305
305
  type: 'input',
@@ -325,9 +325,9 @@ const mainGenerator: PlopGeneratorConfig = {
325
325
  {
326
326
  ...helpGenerator.prompts[0],
327
327
  name: 'helpQuery', // Rename to avoid conflict if needed, though 'query' is fine
328
- when: (answers) =>
329
- answers.mode === 'help' ||
330
- answers.componentType === 'help' ||
328
+ when: (answers) =>
329
+ answers.mode === 'help' ||
330
+ answers.componentType === 'help' ||
331
331
  answers.selectedFile === 'help'
332
332
  }
333
333
  ],
@@ -337,7 +337,7 @@ const mainGenerator: PlopGeneratorConfig = {
337
337
  // Check for help mode first
338
338
  if (data.mode === 'help' || data.componentType === 'help' || data.selectedFile === 'help') {
339
339
  // Help actions (empty usually, as interaction is in prompt)
340
- return [];
340
+ return [];
341
341
  }
342
342
 
343
343
  if (data.mode === 'docs') {
@@ -359,7 +359,7 @@ const mainGenerator: PlopGeneratorConfig = {
359
359
 
360
360
  if (['page_pair', 'snippet', 'orderable', 'model'].includes(data.componentType)) {
361
361
  targetGenerator = wagtailPageGenerator;
362
- data.type = data.componentType;
362
+ data.type = data.componentType;
363
363
  } else if (data.componentType === 'block') {
364
364
  targetGenerator = wagtailBlockGenerator;
365
365
  } else if (data.componentType === 'view') {
@@ -392,23 +392,15 @@ const mainGenerator: PlopGeneratorConfig = {
392
392
  // We need full path for the AI generator.
393
393
  const appName = data.app;
394
394
  const relativePath = data.selectedFile;
395
- // We need to reconstruct absolute path or use analyzeFile to get it again.
396
- // Or simply construct it here.
397
395
  // We need 'filePath' for aiEnhancer.
398
- // Let's use analyzeFile logic or just path.join.
399
- // Ideally we should have passed the info object in the prompt but inquirer is tricky with dependent values.
400
396
 
401
- // Re-analyze or just build path?
402
- // Since we are in node environment here, we can use path module.
403
- // However, Plop actions run in a context where we might not have 'path' easily unless we import it.
404
- // We can use the analyzeFile helper again if needed, or just hardcode construction.
405
- // Since we imported analyzeFile, we can use it.
406
- const fileInfo = analyzeFile(appName, relativePath);
397
+ const fileInfo = analyzeFile(appName, relativePath, data.config);
407
398
  if (fileInfo) {
408
399
  data.filePath = fileInfo.filePath;
409
400
  } else {
410
401
  // Fallback
411
- data.filePath = `app/${appName}/${relativePath}`;
402
+ const appDirName = data.config?.appDir || 'app';
403
+ data.filePath = `${appDirName}/${appName}/${relativePath}`;
412
404
  }
413
405
 
414
406
  const targetGenerator = aiEnhancerGenerator;
@@ -426,4 +418,4 @@ export default function (plop: NodePlopAPI) {
426
418
  plop.setGenerator('generator', mainGenerator);
427
419
  plop.setGenerator('help', helpGenerator);
428
420
  plop.setGenerator('docs', docsGenerator);
429
- }
421
+ }
@@ -1,5 +1,6 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
+ import { findProjectRoot } from './filesystem';
3
4
 
4
5
  export interface AppFileInfo {
5
6
  fileName: string;
@@ -10,10 +11,13 @@ export interface AppFileInfo {
10
11
  /**
11
12
  * Analyzes the selected application to find the main file (models.py) and extract class definitions.
12
13
  * @param appName The name of the application (directory name)
14
+ * @param config Configuration object
13
15
  * @returns AppFileInfo containing filename, absolute path, and list of class names, or null if not found.
14
16
  */
15
- export const analyzeAppFile = (appName: string): AppFileInfo | null => {
16
- const appDir = path.join(process.cwd(), 'app', appName);
17
+ export const analyzeAppFile = (appName: string, config?: any): AppFileInfo | null => {
18
+ const rootDir = findProjectRoot();
19
+ const appDirName = config?.appDir || 'app';
20
+ const appDir = path.join(rootDir, appDirName, appName);
17
21
 
18
22
  // Priority 1: models.py (Standard Django)
19
23
  let targetFile = 'models.py';
@@ -38,10 +42,13 @@ export const analyzeAppFile = (appName: string): AppFileInfo | null => {
38
42
  * Analyzes a specific file within an application.
39
43
  * @param appName The name of the application
40
44
  * @param relativeFilePath The relative path of the file from the app root
45
+ * @param config Configuration object
41
46
  * @returns AppFileInfo containing filename, absolute path, and list of class names, or null if not found.
42
47
  */
43
- export const analyzeFile = (appName: string, relativeFilePath: string): AppFileInfo | null => {
44
- const appDir = path.join(process.cwd(), 'app', appName);
48
+ export const analyzeFile = (appName: string, relativeFilePath: string, config?: any): AppFileInfo | null => {
49
+ const rootDir = findProjectRoot();
50
+ const appDirName = config?.appDir || 'app';
51
+ const appDir = path.join(rootDir, appDirName, appName);
45
52
  const targetPath = path.join(appDir, relativeFilePath);
46
53
 
47
54
  if (!fs.existsSync(targetPath)) {
@@ -64,6 +71,9 @@ export const analyzeFile = (appName: string, relativeFilePath: string): AppFileI
64
71
  * @param content File content
65
72
  */
66
73
  export const extractClassNames = (content: string): string[] => {
74
+ // Nettoyer les commentaires pour éviter les faux positifs
75
+ const cleanContent = content.replace(/#.*$/gm, '');
76
+
67
77
  // Regex to match "class ClassName"
68
78
  // ^\s*class\s+ -> start of line, optional whitespace, "class" keyword, whitespace
69
79
  // (?<name>\w+) -> capture group for name
@@ -72,7 +82,7 @@ export const extractClassNames = (content: string): string[] => {
72
82
  const classes: string[] = [];
73
83
  let match;
74
84
 
75
- while ((match = classRegex.exec(content)) !== null) {
85
+ while ((match = classRegex.exec(cleanContent)) !== null) {
76
86
  if (match.groups && match.groups.name && match.groups.name !== 'Meta') {
77
87
  classes.push(match.groups.name);
78
88
  }
@@ -0,0 +1,43 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import { findProjectRoot } from './filesystem';
4
+
5
+ export interface KaloConfig {
6
+ aiProvider?: string;
7
+ temperature?: number;
8
+ verbosity?: 'minimal' | 'standard' | 'verbose';
9
+ appDir?: string;
10
+ [key: string]: any;
11
+ }
12
+
13
+ /**
14
+ * Charge la configuration à partir de kalo.config.json ou kalo.config.js
15
+ */
16
+ export async function loadConfig(): Promise<KaloConfig> {
17
+ const rootDir = findProjectRoot();
18
+ const jsonPath = path.join(rootDir, 'kalo.config.json');
19
+ const jsPath = path.join(rootDir, 'kalo.config.js');
20
+
21
+ if (fs.existsSync(jsonPath)) {
22
+ try {
23
+ const content = fs.readFileSync(jsonPath, 'utf8');
24
+ return JSON.parse(content);
25
+ } catch (e) {
26
+ console.error(`Erreur lors de la lecture de ${jsonPath}:`, e);
27
+ }
28
+ }
29
+
30
+ if (fs.existsSync(jsPath)) {
31
+ try {
32
+ // Utilisation de import dynamique pour le fichier .js
33
+ // Note: On utilise un timestamp pour éviter le cache de module si nécessaire en dev,
34
+ // mais ici un import simple devrait suffire.
35
+ const module = await import(jsPath);
36
+ return module.default || module;
37
+ } catch (e) {
38
+ console.error(`Erreur lors du chargement de ${jsPath}:`, e);
39
+ }
40
+ }
41
+
42
+ return {};
43
+ }
@@ -1,15 +1,55 @@
1
1
  import * as fs from 'fs';
2
2
  import * as path from 'path';
3
3
 
4
+ /**
5
+ * Recherche le répertoire racine du projet en remontant à partir d'un dossier donné.
6
+ * Cherche la présence d'un fichier package.json qui n'est pas celui du package kalo-cli lui-même.
7
+ *
8
+ * @param startDir - Le dossier de départ pour la recherche.
9
+ * @returns Le chemin absolu vers la racine du projet.
10
+ */
11
+ export const findProjectRoot = (startDir: string = process.cwd()): string => {
12
+ let currentDir = startDir;
13
+
14
+ while (currentDir !== path.parse(currentDir).root) {
15
+ const packageJsonPath = path.join(currentDir, 'package.json');
16
+ if (fs.existsSync(packageJsonPath)) {
17
+ try {
18
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
19
+ // Si on trouve un package.json qui n'est pas celui de kalo-cli, c'est probablement la racine du projet utilisateur
20
+ // Ou si on est dans un projet qui n'a pas encore de kalo-cli installé mais qui a un package.json
21
+ if (pkg.name !== 'kalo-cli' || currentDir.includes('node_modules')) {
22
+ // Si on est dans node_modules, on doit continuer à monter car on a trouvé le package.json de kalo-cli
23
+ if (currentDir.includes('node_modules')) {
24
+ // On continue de monter
25
+ } else {
26
+ return currentDir;
27
+ }
28
+ } else {
29
+ // C'est le package.json de kalo-cli en mode développement local
30
+ return currentDir;
31
+ }
32
+ } catch (e) {
33
+ // En cas d'erreur de lecture, on continue de monter
34
+ }
35
+ }
36
+ currentDir = path.dirname(currentDir);
37
+ }
38
+
39
+ return startDir; // Fallback sur le dossier de départ
40
+ };
41
+
4
42
  /**
5
43
  * Retrieves the list of existing Django applications in the project.
6
- * Scans the 'app' directory in the current working directory.
44
+ * Scans the 'app' directory in the project root.
7
45
  *
8
46
  * @returns {string[]} An array of application names (directory names).
9
47
  * @throws {Error} If filesystem access fails (though basic read errors might propagate).
10
48
  */
11
- export const getAppList = (): string[] => {
12
- const appDir = path.join(process.cwd(), 'app');
49
+ export const getAppList = (config?: any): string[] => {
50
+ const rootDir = findProjectRoot();
51
+ const appDirName = config?.appDir || 'app';
52
+ const appDir = path.join(rootDir, appDirName);
13
53
  if (!fs.existsSync(appDir)) {
14
54
  return [];
15
55
  }
@@ -21,12 +61,15 @@ export const getAppList = (): string[] => {
21
61
  /**
22
62
  * Recursively lists files in an application directory with filtering.
23
63
  * Excludes test files, __init__.py, and specific directories.
24
- *
64
+ *
25
65
  * @param {string} appName - The name of the application.
66
+ * @param {any} config - Configuration object.
26
67
  * @returns {string[]} List of relative file paths from the app root.
27
68
  */
28
- export const getAppFiles = (appName: string): string[] => {
29
- const appDir = path.join(process.cwd(), 'app', appName);
69
+ export const getAppFiles = (appName: string, config?: any): string[] => {
70
+ const rootDir = findProjectRoot();
71
+ const appDirName = config?.appDir || 'app';
72
+ const appDir = path.join(rootDir, appDirName, appName);
30
73
  if (!fs.existsSync(appDir)) {
31
74
  return [];
32
75
  }
@@ -49,6 +92,7 @@ export const getAppFiles = (appName: string): string[] => {
49
92
  // Exclude test files and __init__.py
50
93
  if (
51
94
  dirent.name !== '__init__.py' &&
95
+ !dirent.name.includes('__pycache__') &&
52
96
  !dirent.name.includes('.test.') &&
53
97
  !dirent.name.includes('.spec.') &&
54
98
  dirent.name.endsWith('.py') // Assume we only care about Python files for now based on context
@@ -1,8 +1,9 @@
1
1
  import { ActionType } from 'plop';
2
+ import { extractClassNames } from './analysis';
2
3
 
3
4
  /**
4
5
  * Ensures a suffix is present on a name.
5
- *
6
+ *
6
7
  * @param {string} name - The name to check.
7
8
  * @param {string} suffix - The suffix to ensure.
8
9
  * @returns {string} The name with the suffix.
@@ -24,7 +25,7 @@ interface CreateAppendOptions {
24
25
  /**
25
26
  * Creates a pair of actions: one to ensure the file exists (using a "dumb" template rendering or specific init template),
26
27
  * and one to append the actual content.
27
- *
28
+ *
28
29
  * @param {CreateAppendOptions} options - Configuration options.
29
30
  * @returns {ActionType[]} An array containing the add and append actions.
30
31
  */
@@ -52,10 +53,52 @@ export const createAppendActions = ({
52
53
  return [
53
54
  addAction,
54
55
  {
55
- type: 'append',
56
+ type: 'modify',
56
57
  path,
57
- templateFile,
58
- data: appendData,
58
+ transform: (content, data) => {
59
+ const className = data.name || data.baseName;
60
+ if (className) {
61
+ const existingClasses = extractClassNames(content);
62
+ // Check for exact class name or suffixed versions if relevant
63
+ if (existingClasses.includes(className)) {
64
+ console.log(`\n[SKIP] Class ${className} already exists in ${path}`);
65
+ return content;
66
+ }
67
+
68
+ // Special case for page_pair which might have multiple classes
69
+ if (data.type === 'page_pair' && data.baseName) {
70
+ const indexClass = `${data.baseName}IndexPage`;
71
+ const detailClass = `${data.baseName}Page`;
72
+ if (existingClasses.includes(indexClass) || existingClasses.includes(detailClass)) {
73
+ console.log(`\n[SKIP] Wagtail Pages for ${data.baseName} already exist in ${path}`);
74
+ return content;
75
+ }
76
+ }
77
+ }
78
+
79
+ // If not skipping, we append the template
80
+ // Plop's modify transform doesn't automatically render templates,
81
+ // but since we are in a generator action, we can use the data.
82
+ // However, 'append' is better for just adding at the end.
83
+ // To keep it simple, we use a regex to append at the end of file.
84
+ // We use projectRoot as base for resolve if path is relative
85
+ const fs = require('fs');
86
+ const pathLib = require('path');
87
+
88
+ // We need to find the project root or use a reliable base.
89
+ // In plop, the configPath usually points to the plopfile.
90
+ const baseDir = data.plop.getPlopfilePath() ? pathLib.dirname(data.plop.getPlopfilePath()) : process.cwd();
91
+
92
+ const fullTemplatePath = pathLib.isAbsolute(templateFile)
93
+ ? templateFile
94
+ : pathLib.resolve(baseDir, templateFile);
95
+
96
+ const template = fs.readFileSync(fullTemplatePath, 'utf8');
97
+ return content.trimEnd() + '\n\n' + data.plop.renderString(
98
+ template,
99
+ { ...data, ...appendData }
100
+ );
101
+ }
59
102
  }
60
103
  ];
61
104
  };
@@ -33,9 +33,10 @@ export const wagtailAdminGenerator: PlopGeneratorConfig = {
33
33
  message: 'Nom de l\'application cible',
34
34
  },
35
35
  ],
36
- actions: (data) => {
36
+ actions: (data: any) => {
37
37
  const actions = [];
38
- const appPath = `app/${data.app}`;
38
+ const appDir = data.config?.appDir || 'app';
39
+ const appPath = `${appDir}/${data.app}`;
39
40
  const isView = data.type === 'view';
40
41
 
41
42
  // Ensure suffixes
@@ -68,14 +69,14 @@ export const wagtailAdminGenerator: PlopGeneratorConfig = {
68
69
  skipIfExists: true,
69
70
  });
70
71
 
71
- // If file existed, we might need to append.
72
- // For simplicity, we just append the registration code.
72
+ // If file existed, we might need to append.
73
+ // For simplicity, we just append the registration code.
73
74
  // Ideally we would check for imports, but appending is safer than overwriting.
74
- // We use a different template for appending to avoid duplicate imports if possible,
75
- // but for now re-using the file with imports is "okay" in Python (just messy)
75
+ // We use a different template for appending to avoid duplicate imports if possible,
76
+ // but for now re-using the file with imports is "okay" in Python (just messy)
76
77
  // or we can rely on user to clean up.
77
78
  // BETTER STRATEGY: Create a separate partial for appending without top-level imports if feasible.
78
- // But for this MVP, let's just append the same content.
79
+ // But for this MVP, let's just append the same content.
79
80
  // The user can deduplicate imports.
80
81
 
81
82
  actions.push({
@@ -27,26 +27,23 @@ export const wagtailBlockGenerator: PlopGeneratorConfig = {
27
27
  message: 'Nom de l\'application cible',
28
28
  },
29
29
  ],
30
- actions: (data) => {
30
+ actions: (data: any) => {
31
31
  const actions = [];
32
- const appPath = data?.app || '';
32
+ const appDir = data.config?.appDir || 'app';
33
33
 
34
34
  // Ensure suffix
35
35
  data.name = ensureSuffix(data.name, 'Block');
36
36
 
37
- // This variable blocksFile is not really used by Plop actions directly but was here for calculation
38
- // const blocksFile = path.join('app', appPath, 'blocks.py');
39
-
40
37
  // 1. Ensure blocks.py exists and Append the block
41
38
  actions.push(...createAppendActions({
42
- path: `app/${data.app}/blocks.py`,
39
+ path: `${appDir}/${data.app}/blocks.py`,
43
40
  templateFile: 'generators/wagtail-block/templates/block_class.py.hbs'
44
41
  }));
45
42
 
46
43
  // 3. Create the template
47
44
  actions.push({
48
45
  type: 'add',
49
- path: 'app/{{app}}/templates/{{app}}/blocks/{{snakeCase name}}.html',
46
+ path: `${appDir}/{{app}}/templates/{{app}}/blocks/{{snakeCase name}}.html`,
50
47
  templateFile: 'generators/wagtail-block/templates/block_template.html.hbs',
51
48
  });
52
49
 
@@ -9,7 +9,8 @@ import { createAppendActions } from '../../utils/plop-actions';
9
9
  * @returns {ActionType[]} An array of Plop actions to create/update model files.
10
10
  */
11
11
  export const getModelActions = (data: any): ActionType[] => {
12
- const appPath = `app/${data.app}`;
12
+ const appDir = data.config?.appDir || 'app';
13
+ const appPath = `${appDir}/${data.app}`;
13
14
 
14
15
  return createAppendActions({
15
16
  path: `${appPath}/models/models.py`,
@@ -10,7 +10,8 @@ import { ensureSuffix, createAppendActions } from '../../utils/plop-actions';
10
10
  * @returns {ActionType[]} An array of Plop actions to create/update orderable files.
11
11
  */
12
12
  export const getOrderableActions = (data: any): ActionType[] => {
13
- const appPath = `app/${data.app}`;
13
+ const appDir = data.config?.appDir || 'app';
14
+ const appPath = `${appDir}/${data.app}`;
14
15
 
15
16
  data.name = ensureSuffix(data.name, 'Orderable');
16
17
 
@@ -11,7 +11,8 @@ import { createAppendActions } from '../../utils/plop-actions';
11
11
  */
12
12
  export const getPageActions = (data: any): ActionType[] => {
13
13
  const actions: ActionType[] = [];
14
- const appPath = `app/${data.app}`;
14
+ const appDir = data.config?.appDir || 'app';
15
+ const appPath = `${appDir}/${data.app}`;
15
16
 
16
17
  // Clean base name: Remove 'Page' if present to avoid BlogPagePage
17
18
  const baseName = data.name.replace(/Page$/, '');
@@ -8,7 +8,8 @@ import { createAppendActions, ensureSuffix } from '../../utils/plop-actions';
8
8
  * @returns {ActionType[]} An array of Plop actions to create snippet files and tests.
9
9
  */
10
10
  export const getSnippetActions = (data: any): ActionType[] => {
11
- const appPath = `app/${data.app}`;
11
+ const appDir = data.config?.appDir || 'app';
12
+ const appPath = `${appDir}/${data.app}`;
12
13
 
13
14
  data.name = ensureSuffix(data.name, 'Snippet');
14
15
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kalo-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.2",
4
4
  "description": "Générateur de code structuré et uniforme pour Django et Wagtail",
5
5
  "bin": {
6
6
  "kalo": "./bin/kalo.ts"