kalo-cli 0.1.0 → 0.1.3

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,30 @@
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";
8
+ import { findProjectRoot } from "../generators/utils/filesystem";
6
9
 
7
10
  const args = process.argv.slice(2);
8
11
  const argv = minimist(args);
9
12
 
10
13
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
14
 
15
+ const projectRoot = findProjectRoot(process.cwd());
16
+ const config = await loadConfig();
17
+
12
18
  Plop.prepare({
13
- cwd: argv.cwd,
19
+ cwd: argv.cwd || projectRoot,
14
20
  configPath: path.join(__dirname, '../plopfile.ts'),
15
21
  preload: argv.require || [],
16
22
  completion: argv.completion
17
- }, env => Plop.execute(env, (env) => run(env, undefined, true)));
23
+ }, env => Plop.execute(env, (env) => {
24
+ const options = {
25
+ ...env,
26
+ dest: argv.dest || env.dest,
27
+ config: config // Passer la config au contexte Plop
28
+ };
29
+ return run(options, undefined, true);
30
+ }));
@@ -4,6 +4,7 @@ import { generateWithModel } from '../utils/ai/common';
4
4
  import { injectMarkers, applyGeneratedCode } from '../utils/code-manipulation';
5
5
  import { Glob } from "bun";
6
6
  import { searchChoices } from '../utils/search';
7
+ import { findProjectRoot } from '../utils/filesystem';
7
8
  import { AI_PROVIDER_TYPES, AI_TEMP_TYPES, BACK_VALUE, EXIT_VALUE } from '../constants';
8
9
  import keywordConfig from './keywords.json';
9
10
 
@@ -108,8 +109,9 @@ export const aiEnhancerGenerator: PlopGeneratorConfig = {
108
109
  source: async (answers, input) => {
109
110
  const glob = new Glob("**/*.py");
110
111
  const files: string[] = [];
111
- // Scans the current directory
112
- for await (const file of glob.scan(".")) {
112
+ const rootDir = findProjectRoot();
113
+ // Scans the project root
114
+ for await (const file of glob.scan(rootDir)) {
113
115
  // Filter for likely python model files or snippets
114
116
  if (file.endsWith('.py') && (file.includes("models") || file.includes("snippet"))) {
115
117
  files.push(file);
@@ -144,7 +146,7 @@ export const aiEnhancerGenerator: PlopGeneratorConfig = {
144
146
  name: 'aiProvider',
145
147
  message: 'Select AI Provider:',
146
148
  default: 'Qwen',
147
- when: (answers) => !isBack(answers),
149
+ when: (answers: any) => !isBack(answers) && !answers.config?.aiProvider,
148
150
  source: async (answers, input) => {
149
151
  const choices = AI_PROVIDER_TYPES.map(p => ({ name: p, value: p }));
150
152
  const results = await searchChoices(input, choices);
@@ -158,7 +160,7 @@ export const aiEnhancerGenerator: PlopGeneratorConfig = {
158
160
  name: 'temperature',
159
161
  message: 'Select Creativity Level:',
160
162
  default: 0.5,
161
- when: (answers) => !isBack(answers),
163
+ when: (answers: any) => !isBack(answers) && answers.config?.temperature === undefined,
162
164
  source: async (answers, input) => {
163
165
  const results = await searchChoices(input, AI_TEMP_TYPES);
164
166
  results.push({ name: 'Exit', value: EXIT_VALUE });
@@ -171,7 +173,7 @@ export const aiEnhancerGenerator: PlopGeneratorConfig = {
171
173
  name: 'verbosity',
172
174
  message: 'Select Verbosity Level:',
173
175
  default: 'standard',
174
- when: (answers) => !isBack(answers),
176
+ when: (answers: any) => !isBack(answers) && !answers.config?.verbosity,
175
177
  source: async (answers, input) => {
176
178
  const choices = [
177
179
  { name: 'Minimal (Code only)', value: 'minimal' },
@@ -1,4 +1,6 @@
1
1
  import { PlopGeneratorConfig } from 'plop';
2
+ import * as path from 'path';
3
+ import { findProjectRoot } from '../utils/filesystem';
2
4
 
3
5
  /**
4
6
  * Generator configuration for creating a new Django Application.
@@ -21,47 +23,52 @@ export const djangoAppGenerator: PlopGeneratorConfig = {
21
23
  },
22
24
  },
23
25
  ],
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
- ],
26
+ actions: (data: any) => {
27
+ const rootDir = findProjectRoot();
28
+ const appDirName = data.config?.appDir || 'app';
29
+ const appDir = path.join(rootDir, appDirName);
30
+ return [
31
+ {
32
+ type: 'add',
33
+ path: `${appDir}/__init__.py`,
34
+ templateFile: 'generators/django-app/templates/init.py.hbs',
35
+ skipIfExists: true,
36
+ },
37
+ {
38
+ type: 'add',
39
+ path: `${appDir}/{{name}}/__init__.py`,
40
+ templateFile: 'generators/django-app/templates/init.py.hbs',
41
+ },
42
+ {
43
+ type: 'add',
44
+ path: `${appDir}/{{name}}/apps.py`,
45
+ templateFile: 'generators/django-app/templates/apps.py.hbs',
46
+ },
47
+ {
48
+ type: 'add',
49
+ path: `${appDir}/{{name}}/models/__init__.py`,
50
+ templateFile: 'generators/django-app/templates/models_init.py.hbs',
51
+ },
52
+ {
53
+ type: 'add',
54
+ path: `${appDir}/{{name}}/tests/__init__.py`,
55
+ templateFile: 'generators/django-app/templates/init.py.hbs',
56
+ },
57
+ {
58
+ type: 'add',
59
+ path: `${appDir}/{{name}}/views.py`,
60
+ templateFile: 'generators/django-app/templates/views.py.hbs',
61
+ },
62
+ {
63
+ type: 'add',
64
+ path: `${appDir}/{{name}}/urls.py`,
65
+ templateFile: 'generators/django-app/templates/urls.py.hbs',
66
+ },
67
+ {
68
+ type: 'add',
69
+ path: `${appDir}/{{name}}/admin.py`,
70
+ templateFile: 'generators/django-app/templates/admin.py.hbs',
71
+ },
72
+ ];
73
+ },
67
74
  };
@@ -1,5 +1,7 @@
1
1
  import { PlopGeneratorConfig } from 'plop';
2
+ import * as path from 'path';
2
3
  import { ensureSuffix, createAppendActions } from '../utils/plop-actions';
4
+ import { findProjectRoot } from '../utils/filesystem';
3
5
  import { CHANNEL_TYPES } from '../constants';
4
6
  import { searchChoices } from '../utils/search';
5
7
 
@@ -39,9 +41,12 @@ export const djangoChannelGenerator: PlopGeneratorConfig = {
39
41
  message: 'Nom de l\'application cible',
40
42
  },
41
43
  ],
42
- actions: (data) => {
44
+ actions: (data: any) => {
43
45
  const actions = [];
44
- const appPath = `app/${data.app}`;
46
+ const rootDir = findProjectRoot();
47
+ const appDirName = data.config?.appDir || 'app';
48
+ const appDir = path.join(rootDir, appDirName);
49
+ const appPath = `${appDir}/${data.app}`;
45
50
  const isAsync = data.type === 'async';
46
51
  data.isAsync = isAsync;
47
52
 
@@ -1,5 +1,7 @@
1
1
  import { PlopGeneratorConfig } from 'plop';
2
+ import * as path from 'path';
2
3
  import { ensureSuffix, createAppendActions } from '../utils/plop-actions';
4
+ import { findProjectRoot } from '../utils/filesystem';
3
5
  import { FORM_TYPES } from '../constants';
4
6
  import { searchChoices } from '../utils/search';
5
7
 
@@ -38,15 +40,18 @@ export const djangoFormGenerator: PlopGeneratorConfig = {
38
40
  message: 'Nom de l\'application cible',
39
41
  },
40
42
  ],
41
- actions: (data) => {
43
+ actions: (data: any) => {
42
44
  const actions = [];
43
- const appPath = `app/${data.app}`;
45
+ const rootDir = findProjectRoot();
46
+ const appDirName = data.config?.appDir || 'app';
47
+ const appDir = path.join(rootDir, appDirName);
48
+ const appPath = `${appDir}/${data.app}`;
44
49
  const isModelForm = data.type === 'model_form';
45
50
 
46
51
  // Ensure suffix
47
52
  data.name = ensureSuffix(data.name, 'Form');
48
53
 
49
- const templateFile = isModelForm
54
+ const templateFile = isModelForm
50
55
  ? 'generators/django-form/templates/model_form.py.hbs'
51
56
  : 'generators/django-form/templates/form.py.hbs';
52
57
 
@@ -34,9 +34,12 @@ 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 rootDir = findProjectRoot();
40
+ const appDirName = data.config?.appDir || 'app';
41
+ const appDir = path.join(rootDir, appDirName);
42
+ const appPath = `${appDir}/${data.app}`;
40
43
  const isCBV = data.type !== 'fbv';
41
44
 
42
45
  // 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,93 @@
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 valide (avec name et version).
7
+ * Vérifie également les permissions d'écriture.
8
+ *
9
+ * @param startDir - Le dossier de départ pour la recherche.
10
+ * @returns Le chemin absolu vers la racine du projet.
11
+ */
12
+ export const findProjectRoot = (startDir: string = process.cwd()): string => {
13
+ let currentDir = startDir;
14
+
15
+ while (currentDir !== path.parse(currentDir).root) {
16
+ const packageJsonPath = path.join(currentDir, 'package.json');
17
+ if (fs.existsSync(packageJsonPath)) {
18
+ try {
19
+ const pkg = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'));
20
+
21
+ // Validation basique du package.json
22
+ if (!pkg.name || !pkg.version) {
23
+ // Package.json invalide, on continue
24
+ currentDir = path.dirname(currentDir);
25
+ continue;
26
+ }
27
+
28
+ // Si on trouve un package.json qui n'est pas celui de kalo-cli, c'est probablement la racine du projet utilisateur
29
+ // Ou si on est dans un projet qui n'a pas encore de kalo-cli installé mais qui a un package.json
30
+ if (pkg.name !== 'kalo-cli' || currentDir.includes('node_modules')) {
31
+ // Si on est dans node_modules, on doit continuer à monter car on a trouvé le package.json de kalo-cli
32
+ // SAUF si le dossier courant n'est PAS dans node_modules (ex: projet utilisateur nommé 'node_modules' - improbable mais possible)
33
+ // La logique ici : Si le chemin CONTIENT node_modules, c'est suspect, on veut sortir de node_modules.
34
+ // MAIS si on a trouvé un package.json valide à un niveau, il faut vérifier si c'est celui de notre dépendance ou du projet racine.
35
+
36
+ // Cas simple : On est dans .../mon-projet/node_modules/kalo-cli
37
+ // currentDir = .../mon-projet/node_modules/kalo-cli -> pkg.name = kalo-cli.
38
+ // La condition (pkg.name !== 'kalo-cli') est fausse. -> else -> return currentDir (Mode dev local)
39
+ // ATTENTION: La logique originale retournait currentDir si pkg.name === 'kalo-cli' (mode dev).
40
+
41
+ // Nouvelle logique plus stricte :
42
+ if (currentDir.includes('node_modules')) {
43
+ // On est à l'intérieur d'un node_modules, ce n'est probablement pas la racine du projet utilisateur
44
+ // On continue de remonter
45
+ } else {
46
+ // Vérification des permissions d'écriture
47
+ try {
48
+ fs.accessSync(currentDir, fs.constants.W_OK);
49
+ return currentDir;
50
+ } catch (e) {
51
+ console.warn(`[Kalo] Attention: Le répertoire détecté ${currentDir} n'est pas accessible en écriture.`);
52
+ // On continue quand même ? Ou on retourne avec un warning ?
53
+ // Pour l'instant on retourne, l'erreur surviendra à l'écriture.
54
+ return currentDir;
55
+ }
56
+ }
57
+ } else {
58
+ // C'est le package.json de kalo-cli (pkg.name === 'kalo-cli')
59
+ // Si on n'est pas dans node_modules, c'est qu'on développe kalo-cli lui-même
60
+ if (!currentDir.includes('node_modules')) {
61
+ return currentDir;
62
+ }
63
+ // Sinon, c'est qu'on est dans node_modules/kalo-cli, donc on continue
64
+ }
65
+ } catch (e) {
66
+ // En cas d'erreur de lecture, on continue de monter
67
+ }
68
+ }
69
+ currentDir = path.dirname(currentDir);
70
+ }
71
+
72
+ // Fallback: Si on n'a rien trouvé, on retourne le dossier de départ
73
+ // Mais on prévient l'utilisateur
74
+ if (startDir.includes('node_modules')) {
75
+ console.warn("[Kalo] Attention: Impossible de trouver la racine du projet hors de node_modules.");
76
+ }
77
+ return startDir;
78
+ };
79
+
4
80
  /**
5
81
  * Retrieves the list of existing Django applications in the project.
6
- * Scans the 'app' directory in the current working directory.
82
+ * Scans the 'app' directory in the project root.
7
83
  *
8
84
  * @returns {string[]} An array of application names (directory names).
9
85
  * @throws {Error} If filesystem access fails (though basic read errors might propagate).
10
86
  */
11
- export const getAppList = (): string[] => {
12
- const appDir = path.join(process.cwd(), 'app');
87
+ export const getAppList = (config?: any): string[] => {
88
+ const rootDir = findProjectRoot();
89
+ const appDirName = config?.appDir || 'app';
90
+ const appDir = path.join(rootDir, appDirName);
13
91
  if (!fs.existsSync(appDir)) {
14
92
  return [];
15
93
  }
@@ -21,12 +99,15 @@ export const getAppList = (): string[] => {
21
99
  /**
22
100
  * Recursively lists files in an application directory with filtering.
23
101
  * Excludes test files, __init__.py, and specific directories.
24
- *
102
+ *
25
103
  * @param {string} appName - The name of the application.
104
+ * @param {any} config - Configuration object.
26
105
  * @returns {string[]} List of relative file paths from the app root.
27
106
  */
28
- export const getAppFiles = (appName: string): string[] => {
29
- const appDir = path.join(process.cwd(), 'app', appName);
107
+ export const getAppFiles = (appName: string, config?: any): string[] => {
108
+ const rootDir = findProjectRoot();
109
+ const appDirName = config?.appDir || 'app';
110
+ const appDir = path.join(rootDir, appDirName, appName);
30
111
  if (!fs.existsSync(appDir)) {
31
112
  return [];
32
113
  }
@@ -49,6 +130,7 @@ export const getAppFiles = (appName: string): string[] => {
49
130
  // Exclude test files and __init__.py
50
131
  if (
51
132
  dirent.name !== '__init__.py' &&
133
+ !dirent.name.includes('__pycache__') &&
52
134
  !dirent.name.includes('.test.') &&
53
135
  !dirent.name.includes('.spec.') &&
54
136
  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
  };
@@ -1,5 +1,7 @@
1
1
  import { PlopGeneratorConfig } from 'plop';
2
+ import * as path from 'path';
2
3
  import { ensureSuffix } from '../utils/plop-actions';
4
+ import { findProjectRoot } from '../utils/filesystem';
3
5
  import { ADMIN_EXT_TYPES } from '../constants';
4
6
  import { searchChoices } from '../utils/search';
5
7
 
@@ -33,9 +35,12 @@ export const wagtailAdminGenerator: PlopGeneratorConfig = {
33
35
  message: 'Nom de l\'application cible',
34
36
  },
35
37
  ],
36
- actions: (data) => {
38
+ actions: (data: any) => {
37
39
  const actions = [];
38
- const appPath = `app/${data.app}`;
40
+ const rootDir = findProjectRoot();
41
+ const appDirName = data.config?.appDir || 'app';
42
+ const appDir = path.join(rootDir, appDirName);
43
+ const appPath = `${appDir}/${data.app}`;
39
44
  const isView = data.type === 'view';
40
45
 
41
46
  // Ensure suffixes
@@ -68,14 +73,14 @@ export const wagtailAdminGenerator: PlopGeneratorConfig = {
68
73
  skipIfExists: true,
69
74
  });
70
75
 
71
- // If file existed, we might need to append.
72
- // For simplicity, we just append the registration code.
76
+ // If file existed, we might need to append.
77
+ // For simplicity, we just append the registration code.
73
78
  // 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)
79
+ // We use a different template for appending to avoid duplicate imports if possible,
80
+ // but for now re-using the file with imports is "okay" in Python (just messy)
76
81
  // or we can rely on user to clean up.
77
82
  // 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.
83
+ // But for this MVP, let's just append the same content.
79
84
  // The user can deduplicate imports.
80
85
 
81
86
  actions.push({
@@ -2,6 +2,7 @@ import { PlopGeneratorConfig } from 'plop';
2
2
  import * as fs from 'fs';
3
3
  import * as path from 'path';
4
4
  import { ensureSuffix, createAppendActions } from '../utils/plop-actions';
5
+ import { findProjectRoot } from '../utils/filesystem';
5
6
 
6
7
  /**
7
8
  * Generator configuration for Wagtail StreamField Blocks.
@@ -27,26 +28,25 @@ export const wagtailBlockGenerator: PlopGeneratorConfig = {
27
28
  message: 'Nom de l\'application cible',
28
29
  },
29
30
  ],
30
- actions: (data) => {
31
+ actions: (data: any) => {
31
32
  const actions = [];
32
- const appPath = data?.app || '';
33
+ const rootDir = findProjectRoot();
34
+ const appDirName = data.config?.appDir || 'app';
35
+ const appDir = path.join(rootDir, appDirName);
33
36
 
34
37
  // Ensure suffix
35
38
  data.name = ensureSuffix(data.name, 'Block');
36
39
 
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
40
  // 1. Ensure blocks.py exists and Append the block
41
41
  actions.push(...createAppendActions({
42
- path: `app/${data.app}/blocks.py`,
42
+ path: `${appDir}/${data.app}/blocks.py`,
43
43
  templateFile: 'generators/wagtail-block/templates/block_class.py.hbs'
44
44
  }));
45
45
 
46
46
  // 3. Create the template
47
47
  actions.push({
48
48
  type: 'add',
49
- path: 'app/{{app}}/templates/{{app}}/blocks/{{snakeCase name}}.html',
49
+ path: `${appDir}/{{app}}/templates/{{app}}/blocks/{{snakeCase name}}.html`,
50
50
  templateFile: 'generators/wagtail-block/templates/block_template.html.hbs',
51
51
  });
52
52
 
@@ -1,6 +1,8 @@
1
1
 
2
2
  import { ActionType } from 'plop';
3
+ import * as path from 'path';
3
4
  import { createAppendActions } from '../../utils/plop-actions';
5
+ import { findProjectRoot } from '../../utils/filesystem';
4
6
 
5
7
  /**
6
8
  * Generates Plop actions for creating a standard Django model.
@@ -9,7 +11,10 @@ import { createAppendActions } from '../../utils/plop-actions';
9
11
  * @returns {ActionType[]} An array of Plop actions to create/update model files.
10
12
  */
11
13
  export const getModelActions = (data: any): ActionType[] => {
12
- const appPath = `app/${data.app}`;
14
+ const rootDir = findProjectRoot();
15
+ const appDirName = data.config?.appDir || 'app';
16
+ const appDir = path.join(rootDir, appDirName);
17
+ const appPath = `${appDir}/${data.app}`;
13
18
 
14
19
  return createAppendActions({
15
20
  path: `${appPath}/models/models.py`,
@@ -1,6 +1,8 @@
1
1
 
2
2
  import { ActionType } from 'plop';
3
+ import * as path from 'path';
3
4
  import { ensureSuffix, createAppendActions } from '../../utils/plop-actions';
5
+ import { findProjectRoot } from '../../utils/filesystem';
4
6
 
5
7
  /**
6
8
  * Generates Plop actions for creating a Wagtail Orderable model.
@@ -10,7 +12,10 @@ import { ensureSuffix, createAppendActions } from '../../utils/plop-actions';
10
12
  * @returns {ActionType[]} An array of Plop actions to create/update orderable files.
11
13
  */
12
14
  export const getOrderableActions = (data: any): ActionType[] => {
13
- const appPath = `app/${data.app}`;
15
+ const rootDir = findProjectRoot();
16
+ const appDirName = data.config?.appDir || 'app';
17
+ const appDir = path.join(rootDir, appDirName);
18
+ const appPath = `${appDir}/${data.app}`;
14
19
 
15
20
  data.name = ensureSuffix(data.name, 'Orderable');
16
21
 
@@ -1,6 +1,8 @@
1
1
 
2
2
  import { ActionType } from 'plop';
3
+ import * as path from 'path';
3
4
  import { createAppendActions } from '../../utils/plop-actions';
5
+ import { findProjectRoot } from '../../utils/filesystem';
4
6
 
5
7
  /**
6
8
  * Generates Plop actions for creating a pair of Wagtail Pages (Index + Detail).
@@ -11,7 +13,10 @@ import { createAppendActions } from '../../utils/plop-actions';
11
13
  */
12
14
  export const getPageActions = (data: any): ActionType[] => {
13
15
  const actions: ActionType[] = [];
14
- const appPath = `app/${data.app}`;
16
+ const rootDir = findProjectRoot();
17
+ const appDirName = data.config?.appDir || 'app';
18
+ const appDir = path.join(rootDir, appDirName);
19
+ const appPath = `${appDir}/${data.app}`;
15
20
 
16
21
  // Clean base name: Remove 'Page' if present to avoid BlogPagePage
17
22
  const baseName = data.name.replace(/Page$/, '');
@@ -8,7 +8,10 @@ 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 rootDir = findProjectRoot();
12
+ const appDirName = data.config?.appDir || 'app';
13
+ const appDir = path.join(rootDir, appDirName);
14
+ const appPath = `${appDir}/${data.app}`;
12
15
 
13
16
  data.name = ensureSuffix(data.name, 'Snippet');
14
17
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kalo-cli",
3
- "version": "0.1.0",
3
+ "version": "0.1.3",
4
4
  "description": "Générateur de code structuré et uniforme pour Django et Wagtail",
5
5
  "bin": {
6
6
  "kalo": "./bin/kalo.ts"