dhti-cli 1.0.1 → 1.1.0

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.
@@ -9,8 +9,9 @@ export default class Conch extends Command {
9
9
  branch: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
10
  'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
11
  git: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ local: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
13
  name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
- sources: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
+ sources: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  workdir: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
16
  };
16
17
  run(): Promise<void>;
@@ -15,6 +15,7 @@ export default class Conch extends Command {
15
15
  '<%= config.bin %> <%= command.id %> install -n my-app -w ~/projects',
16
16
  '<%= config.bin %> <%= command.id %> init -n my-app -w ~/projects',
17
17
  '<%= config.bin %> <%= command.id %> start -n my-app -w ~/projects',
18
+ '<%= config.bin %> <%= command.id %> start -n my-app -w ~/projects -s packages/chatbot -s packages/utils',
18
19
  ];
19
20
  static flags = {
20
21
  branch: Flags.string({
@@ -31,10 +32,15 @@ export default class Conch extends Command {
31
32
  default: 'dermatologist/openmrs-esm-dhti-template',
32
33
  description: 'GitHub repository to install (for install operation)',
33
34
  }),
35
+ local: Flags.string({
36
+ char: 'l',
37
+ description: 'Local path to use instead of calculated workdir/name path (for start operation)',
38
+ }),
34
39
  name: Flags.string({ char: 'n', description: 'Name of the conch' }),
35
40
  sources: Flags.string({
36
41
  char: 's',
37
- description: 'Additional sources to include when starting (e.g., packages/esm-chatbot-agent)',
42
+ description: 'Additional sources to include when starting (e.g., packages/esm-chatbot-agent, packages/esm-another-app)',
43
+ multiple: true,
38
44
  }),
39
45
  workdir: Flags.string({
40
46
  char: 'w',
@@ -91,21 +97,26 @@ export default class Conch extends Command {
91
97
  }
92
98
  if (args.op === 'start') {
93
99
  // Validate required flags
94
- if (!flags.workdir) {
95
- console.error(chalk.red('Error: workdir flag is required for start operation'));
96
- this.exit(1);
97
- }
98
- if (!flags.name) {
99
- console.error(chalk.red('Error: name flag is required for start operation'));
100
- this.exit(1);
100
+ if (!flags.local) {
101
+ // If --local is not provided, require workdir and name
102
+ if (!flags.workdir) {
103
+ console.error(chalk.red('Error: workdir flag is required for start operation (unless --local is provided)'));
104
+ this.exit(1);
105
+ }
106
+ if (!flags.name) {
107
+ console.error(chalk.red('Error: name flag is required for start operation (unless --local is provided)'));
108
+ this.exit(1);
109
+ }
101
110
  }
102
- const targetDir = path.join(flags.workdir, flags.name);
111
+ const targetDir = flags.local || path.join(flags.workdir, flags.name);
103
112
  if (flags['dry-run']) {
104
113
  console.log(chalk.yellow('[DRY RUN] Would execute start operation:'));
105
114
  console.log(chalk.cyan(` cd ${targetDir}`));
106
115
  let dryRunCommand = 'corepack enable && yarn install && yarn start';
107
- if (flags.sources) {
108
- dryRunCommand += ` --sources '${flags.sources}'`;
116
+ if (flags.sources && flags.sources.length > 0) {
117
+ for (const source of flags.sources) {
118
+ dryRunCommand += ` --sources '${source}'`;
119
+ }
109
120
  }
110
121
  console.log(chalk.cyan(` ${dryRunCommand}`));
111
122
  return;
@@ -113,7 +124,9 @@ export default class Conch extends Command {
113
124
  // Check if directory exists (not in dry-run mode)
114
125
  if (!fs.existsSync(targetDir)) {
115
126
  console.error(chalk.red(`Error: Directory does not exist: ${targetDir}`));
116
- console.log(chalk.yellow(`Run 'dhti-cli conch init -n ${flags.name} -w ${flags.workdir}' first`));
127
+ if (!flags.local) {
128
+ console.log(chalk.yellow(`Run 'dhti-cli conch init -n ${flags.name} -w ${flags.workdir}' first`));
129
+ }
117
130
  this.exit(1);
118
131
  }
119
132
  try {
@@ -121,8 +134,10 @@ export default class Conch extends Command {
121
134
  console.log(chalk.yellow('Press Ctrl-C to stop\n'));
122
135
  // Build the start command with sources flag if provided
123
136
  let startCommand = 'corepack enable && yarn install && yarn start';
124
- if (flags.sources) {
125
- startCommand += ` --sources '${flags.sources}'`;
137
+ if (flags.sources && flags.sources.length > 0) {
138
+ for (const source of flags.sources) {
139
+ startCommand += ` --sources '${source}'`;
140
+ }
126
141
  }
127
142
  // Spawn corepack enable && yarn install && yarn start with stdio inheritance to show output and allow Ctrl-C
128
143
  const child = spawn(startCommand, {
@@ -166,7 +181,7 @@ export default class Conch extends Command {
166
181
  this.exit(1);
167
182
  }
168
183
  // Warn if sources flag is used with install (not applicable)
169
- if (flags.sources) {
184
+ if (flags.sources && flags.sources.length > 0) {
170
185
  console.warn(chalk.yellow('Warning: --sources flag is not applicable for install operation. It will be ignored.'));
171
186
  console.warn(chalk.yellow('Use --sources with the start operation instead.'));
172
187
  }
@@ -185,8 +200,10 @@ export default class Conch extends Command {
185
200
  console.log(chalk.green(`\n✓ Installation complete! Your app is ready at ${targetDir}`));
186
201
  console.log(chalk.blue(`\nTo start development, run:`));
187
202
  let startCmd = `dhti-cli conch start -n ${flags.name} -w ${flags.workdir}`;
188
- if (flags.sources) {
189
- startCmd += ` -s '${flags.sources}'`;
203
+ if (flags.sources && flags.sources.length > 0) {
204
+ for (const source of flags.sources) {
205
+ startCmd += ` -s '${source}'`;
206
+ }
190
207
  }
191
208
  console.log(chalk.cyan(` ${startCmd}`));
192
209
  }
@@ -299,43 +299,46 @@ export default class Elixir extends Command {
299
299
  }
300
300
  return;
301
301
  }
302
- // Create a directory to install the elixir
303
- if (!fs.existsSync(`${flags.workdir}/elixir`)) {
302
+ // Create a directory to install the elixir (only on first install)
303
+ const elixirDir = `${flags.workdir}/elixir`;
304
+ const isFirstInstall = !fs.existsSync(elixirDir);
305
+ if (isFirstInstall) {
304
306
  if (flags['dry-run']) {
305
- console.log(chalk.yellow(`[DRY RUN] Would create directory: ${flags.workdir}/elixir`));
307
+ console.log(chalk.yellow(`[DRY RUN] Would create directory: ${elixirDir}`));
308
+ console.log(chalk.yellow(`[DRY RUN] Would copy resources from ${RESOURCES_DIR}/genai to ${elixirDir}`));
306
309
  }
307
310
  else {
308
- fs.mkdirSync(`${flags.workdir}/elixir`);
311
+ fs.mkdirSync(elixirDir);
312
+ fs.cpSync(path.join(RESOURCES_DIR, 'genai'), elixirDir, { recursive: true });
313
+ console.log(chalk.blue(`✓ Initialized elixir directory at ${elixirDir}`));
309
314
  }
310
315
  }
311
- if (flags['dry-run']) {
312
- console.log(chalk.yellow(`[DRY RUN] Would copy resources from ${RESOURCES_DIR}/genai to ${flags.workdir}/elixir`));
316
+ else if (args.op === 'install') {
317
+ console.log(chalk.blue(`Using existing elixir directory at ${elixirDir}`));
313
318
  }
314
- else {
315
- fs.cpSync(path.join(RESOURCES_DIR, 'genai'), `${flags.workdir}/elixir`, { recursive: true });
316
- }
317
- // if whl is not none, copy the whl file to thee whl directory
319
+ // if whl is not none, copy the whl file to the whl directory
318
320
  if (flags.whl !== 'none') {
319
- if (!fs.existsSync(`${flags.workdir}/elixir/whl/`)) {
321
+ const whlDir = `${elixirDir}/whl/`;
322
+ if (!fs.existsSync(whlDir)) {
320
323
  if (flags['dry-run']) {
321
- console.log(chalk.yellow(`[DRY RUN] Would create directory: ${flags.workdir}/whl/`));
324
+ console.log(chalk.yellow(`[DRY RUN] Would create directory: ${whlDir}`));
322
325
  }
323
326
  else {
324
- fs.mkdirSync(`${flags.workdir}/whl/`);
327
+ fs.mkdirSync(whlDir);
325
328
  }
326
329
  }
327
330
  if (flags['dry-run']) {
328
- console.log(chalk.yellow(`[DRY RUN] Would copy ${flags.whl} to ${flags.workdir}/elixir/whl/${path.basename(flags.whl)}`));
329
- console.log(chalk.cyan('[DRY RUN] Installing elixir from whl file. Please modify boostrap.py file if needed'));
331
+ console.log(chalk.yellow(`[DRY RUN] Would copy ${flags.whl} to ${whlDir}${path.basename(flags.whl)}`));
332
+ console.log(chalk.cyan('[DRY RUN] Installing elixir from whl file. Please modify bootstrap.py file if needed'));
330
333
  }
331
334
  else {
332
- fs.cpSync(flags.whl, `${flags.workdir}/elixir/whl/${path.basename(flags.whl)}`);
333
- console.log('Installing elixir from whl file. Please modify boostrap.py file if needed');
335
+ fs.cpSync(flags.whl, `${whlDir}${path.basename(flags.whl)}`);
336
+ console.log('Installing elixir from whl file. Please modify bootstrap.py file if needed');
334
337
  }
335
338
  }
336
339
  // Install the elixir from git adding to the pyproject.toml file
337
- let pyproject = flags['dry-run'] ? '' : fs.readFileSync(`${flags.workdir}/elixir/pyproject.toml`, 'utf8');
338
- const originalServer = flags['dry-run'] ? '' : fs.readFileSync(`${flags.workdir}/elixir/app/server.py`, 'utf8');
340
+ // Always read from the current state, not the template
341
+ let pyproject = flags['dry-run'] ? '' : fs.readFileSync(`${elixirDir}/pyproject.toml`, 'utf8');
339
342
  let lineToAdd = '';
340
343
  if (flags.whl !== 'none') {
341
344
  lineToAdd = `${flags.name} = { file = "whl/${path.basename(flags.whl)}" }`;
@@ -366,11 +369,49 @@ export default class Elixir extends Command {
366
369
  }
367
370
  lineToAdd = `${flags.name} = { path = "${absolutePath}" }`;
368
371
  }
372
+ // Helper function to add dependency to pyproject.toml
373
+ const addDependencyToPyproject = (content, depName) => {
374
+ // Check if dependency already exists
375
+ if (content.includes(`"${depName}"`)) {
376
+ return content;
377
+ }
378
+ // Add to dependencies array
379
+ return content.replace('dependencies = [', `dependencies = [\n"${depName}",`);
380
+ };
381
+ // Helper function to add source to pyproject.toml
382
+ const addSourceToPyproject = (content, source) => {
383
+ // Check if source already exists (by checking for the package name)
384
+ if (content.includes(`${flags.name} =`)) {
385
+ return content;
386
+ }
387
+ // Add to [tool.uv.sources] section
388
+ return content.replace('[tool.uv.sources]', `[tool.uv.sources]\n${source}\n`);
389
+ };
390
+ // Helper function to remove dependency from pyproject.toml
391
+ const removeDependencyFromPyproject = (content, depName) => {
392
+ // Remove from dependencies array - handle both formats: "depName", or "depName",\n
393
+ let result = content.replace(`"${depName}",`, '').replace(`"${depName}"`, '');
394
+ // Also try to remove with newline variations
395
+ result = result.replace(`\n"${depName}",`, '').replace(`"${depName}",\n`, '');
396
+ return result;
397
+ };
398
+ // Helper function to remove source from pyproject.toml
399
+ const removeSourceFromPyproject = (content, pkgName) => {
400
+ // Remove source line for this package
401
+ const sourceRegex = new RegExp(`${pkgName}\\s*=\\s*\\{[^}]*\\}\n?`, 'g');
402
+ return content.replace(sourceRegex, '');
403
+ };
404
+ let newPyproject = pyproject;
369
405
  if (!flags['dry-run']) {
370
- pyproject = pyproject.replace('dependencies = [', `dependencies = [\n"${flags.name}",`);
371
- pyproject = pyproject.replace('[tool.uv.sources]', `[tool.uv.sources]\n${lineToAdd}\n`);
406
+ if (args.op === 'install') {
407
+ newPyproject = addDependencyToPyproject(pyproject, flags.name);
408
+ newPyproject = addSourceToPyproject(newPyproject, lineToAdd);
409
+ }
410
+ else if (args.op === 'uninstall') {
411
+ newPyproject = removeDependencyFromPyproject(pyproject, flags.name);
412
+ newPyproject = removeSourceFromPyproject(newPyproject, flags.name);
413
+ }
372
414
  }
373
- const newPyproject = pyproject;
374
415
  // Add the elixir import and bootstrap to the server.py file
375
416
  let CliImport = `from ${expoName}.bootstrap import bootstrap as ${expoName}_bootstrap\n`;
376
417
  CliImport += `${expoName}_bootstrap()\n`;
@@ -380,54 +421,99 @@ ${expoName}_chain = ${expoName}_chain_class().get_chain_as_langchain_tool()
380
421
  ${expoName}_mcp_tool = ${expoName}_chain_class().get_chain_as_mcp_tool
381
422
  mcp_server.add_tool(${expoName}_mcp_tool) # type: ignore
382
423
  `;
383
- let newCliImport = '';
384
- if (!flags['dry-run']) {
385
- newCliImport = fs
386
- .readFileSync(`${flags.workdir}/elixir/app/server.py`, 'utf8')
387
- .replace('# DHTI_CLI_IMPORT', `#DHTI_CLI_IMPORT\n${CliImport}`);
388
- }
389
424
  const langfuseRoute = `add_routes(app, ${expoName}_chain.with_config(config), path="/langserve/${expoName}")`;
390
- const newLangfuseRoute = flags['dry-run']
391
- ? ''
392
- : newCliImport.replace('# DHTI_LANGFUSE_ROUTE', `#DHTI_LANGFUSE_ROUTE\n ${langfuseRoute}`);
393
425
  const normalRoute = `add_routes(app, ${expoName}_chain, path="/langserve/${expoName}")`;
394
- const newNormalRoute = flags['dry-run']
395
- ? ''
396
- : newLangfuseRoute.replace('# DHTI_NORMAL_ROUTE', `#DHTI_NORMAL_ROUTE\n ${normalRoute}`);
397
426
  const commonRoutes = `\nadd_invokes(app, path="/langserve/${expoName}")\nadd_services(app, path="/langserve/${expoName}")`;
398
- const finalRoute = flags['dry-run']
399
- ? ''
400
- : newNormalRoute.replace('# DHTI_COMMON_ROUTE', `#DHTI_COMMON_ROUTES${commonRoutes}`);
401
- // if args.op === install, add the line to the pyproject.toml file
427
+ // Helper function to add elixir to server.py
428
+ // The strategy is to append imports BEFORE the 'import uvicorn' line
429
+ // and append routes AFTER the route marker comments
430
+ const addElixirToServer = (elixirName) => {
431
+ // Read fresh file content each time to get the current state
432
+ const content = flags['dry-run'] ? '' : fs.readFileSync(`${elixirDir}/app/server.py`, 'utf8');
433
+ if (flags['dry-run'] || content === '') {
434
+ return '';
435
+ }
436
+ // Check if elixir already installed
437
+ if (content.includes(`${elixirName}_bootstrap`)) {
438
+ return content;
439
+ }
440
+ let result = content;
441
+ // Find where to insert the import - look for 'import uvicorn' and insert before it
442
+ // This way, all imports are together before uvicorn import
443
+ if (!result.includes('import uvicorn')) {
444
+ console.error(chalk.red('Error: Could not find "import uvicorn" marker in server.py'));
445
+ return content;
446
+ }
447
+ result = result.replace('import uvicorn', `${CliImport}\nimport uvicorn`);
448
+ // For routes, we need to insert into both langfuse and normal route sections
449
+ // But only if they exist
450
+ // Find the langfuse try block and insert before 'except'
451
+ if (result.includes('except:')) {
452
+ // Insert langfuse route before except with proper indentation
453
+ result = result.replace('except:', ` ${langfuseRoute}\n\nexcept:`);
454
+ // Insert normal route in the except block, after the '# DHTI_NORMAL_ROUTE' marker
455
+ result = result.replace('# DHTI_NORMAL_ROUTE\n', `# DHTI_NORMAL_ROUTE\n ${normalRoute}\n`);
456
+ }
457
+ // For common routes, look for the marker and append with proper indentation
458
+ const commonRoutesMarker = '# DHTI_COMMON_ROUTE';
459
+ if (result.includes(commonRoutesMarker)) {
460
+ // Get the exact indentation by checking what comes after the marker
461
+ const markerIndex = result.indexOf(commonRoutesMarker);
462
+ const afterMarker = result.substring(markerIndex + commonRoutesMarker.length);
463
+ const newlineAndIndent = afterMarker.match(/\n[ \t]*/)?.[0] || '\n';
464
+ result = result.replace(commonRoutesMarker, `${commonRoutesMarker}${newlineAndIndent}${commonRoutes}`);
465
+ }
466
+ return result;
467
+ };
468
+ // Helper function to remove elixir from server.py
469
+ const removeElixirFromServer = () => {
470
+ // Read fresh file content each time to get the current state
471
+ const content = flags['dry-run'] ? '' : fs.readFileSync(`${elixirDir}/app/server.py`, 'utf8');
472
+ if (flags['dry-run'] || content === '') {
473
+ return '';
474
+ }
475
+ let result = content;
476
+ result = result.replace(CliImport, '');
477
+ result = result.replace(langfuseRoute, '');
478
+ result = result.replace(normalRoute, '');
479
+ result = result.replace(commonRoutes, '');
480
+ return result;
481
+ };
482
+ let finalRoute = '';
483
+ if (!flags['dry-run'] && args.op === 'install') {
484
+ finalRoute = addElixirToServer(expoName);
485
+ }
486
+ else if (!flags['dry-run'] && args.op === 'uninstall') {
487
+ finalRoute = removeElixirFromServer();
488
+ }
402
489
  if (args.op === 'install') {
403
490
  if (flags['dry-run']) {
404
491
  console.log(chalk.yellow('[DRY RUN] Would update files:'));
405
- console.log(chalk.cyan(` - ${flags.workdir}/elixir/pyproject.toml`));
492
+ console.log(chalk.cyan(` - ${elixirDir}/pyproject.toml`));
406
493
  console.log(chalk.green(` Add dependency: "${flags.name}"`));
407
494
  console.log(chalk.green(` Add source: ${lineToAdd}`));
408
- console.log(chalk.cyan(` - ${flags.workdir}/elixir/app/server.py`));
495
+ console.log(chalk.cyan(` - ${elixirDir}/app/server.py`));
409
496
  console.log(chalk.green(` Add import and routes for ${expoName}`));
410
497
  }
411
498
  else {
412
- fs.writeFileSync(`${flags.workdir}/elixir/pyproject.toml`, newPyproject);
413
- fs.writeFileSync(`${flags.workdir}/elixir/app/server.py`, finalRoute);
499
+ fs.writeFileSync(`${elixirDir}/pyproject.toml`, newPyproject);
500
+ fs.writeFileSync(`${elixirDir}/app/server.py`, finalRoute);
501
+ console.log(chalk.green(`✓ Elixir '${flags.name}' installed successfully`));
414
502
  }
415
503
  }
416
504
  if (args.op === 'uninstall') {
417
505
  if (flags['dry-run']) {
418
506
  console.log(chalk.yellow('[DRY RUN] Would update files:'));
419
- console.log(chalk.cyan(` - ${flags.workdir}/elixir/pyproject.toml`));
507
+ console.log(chalk.cyan(` - ${elixirDir}/pyproject.toml`));
508
+ console.log(chalk.green(` Remove dependency: "${flags.name}"`));
420
509
  console.log(chalk.green(` Remove source: ${lineToAdd}`));
421
- console.log(chalk.cyan(` - ${flags.workdir}/elixir/app/server.py`));
510
+ console.log(chalk.cyan(` - ${elixirDir}/app/server.py`));
422
511
  console.log(chalk.green(` Remove import and routes for ${expoName}`));
423
512
  }
424
513
  else {
425
- // if args.op === uninstall, remove the line from the pyproject.toml file
426
- fs.writeFileSync(`${flags.workdir}/elixir/pyproject.toml`, pyproject.replace(lineToAdd, ''));
427
- let newServer = originalServer.replace(CliImport, '');
428
- newServer = newServer.replace(langfuseRoute, '');
429
- newServer = newServer.replace(normalRoute, '');
430
- fs.writeFileSync(`${flags.workdir}/elixir/app/server.py`, newServer);
514
+ fs.writeFileSync(`${elixirDir}/pyproject.toml`, newPyproject);
515
+ fs.writeFileSync(`${elixirDir}/app/server.py`, finalRoute);
516
+ console.log(chalk.green(`✓ Elixir '${flags.name}' uninstalled successfully`));
431
517
  }
432
518
  }
433
519
  }
@@ -215,7 +215,7 @@ services:
215
215
  environment:
216
216
  - REDIS_HOSTS=local:redis:6379
217
217
  ports:
218
- - "8081:8081"
218
+ - "8181:8081"
219
219
 
220
220
  neo4j:
221
221
  image: neo4j:5.1-enterprise
@@ -95,7 +95,8 @@
95
95
  "examples": [
96
96
  "<%= config.bin %> <%= command.id %> install -n my-app -w ~/projects",
97
97
  "<%= config.bin %> <%= command.id %> init -n my-app -w ~/projects",
98
- "<%= config.bin %> <%= command.id %> start -n my-app -w ~/projects"
98
+ "<%= config.bin %> <%= command.id %> start -n my-app -w ~/projects",
99
+ "<%= config.bin %> <%= command.id %> start -n my-app -w ~/projects -s packages/chatbot -s packages/utils"
99
100
  ],
100
101
  "flags": {
101
102
  "branch": {
@@ -122,6 +123,14 @@
122
123
  "multiple": false,
123
124
  "type": "option"
124
125
  },
126
+ "local": {
127
+ "char": "l",
128
+ "description": "Local path to use instead of calculated workdir/name path (for start operation)",
129
+ "name": "local",
130
+ "hasDynamicHelp": false,
131
+ "multiple": false,
132
+ "type": "option"
133
+ },
125
134
  "name": {
126
135
  "char": "n",
127
136
  "description": "Name of the conch",
@@ -132,10 +141,10 @@
132
141
  },
133
142
  "sources": {
134
143
  "char": "s",
135
- "description": "Additional sources to include when starting (e.g., packages/esm-chatbot-agent)",
144
+ "description": "Additional sources to include when starting (e.g., packages/esm-chatbot-agent, packages/esm-another-app)",
136
145
  "name": "sources",
137
146
  "hasDynamicHelp": false,
138
- "multiple": false,
147
+ "multiple": true,
139
148
  "type": "option"
140
149
  },
141
150
  "workdir": {
@@ -796,5 +805,5 @@
796
805
  ]
797
806
  }
798
807
  },
799
- "version": "1.0.1"
808
+ "version": "1.1.0"
800
809
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dhti-cli",
3
3
  "description": "DHTI CLI",
4
- "version": "1.0.1",
4
+ "version": "1.1.0",
5
5
  "author": "Bell Eapen",
6
6
  "bin": {
7
7
  "dhti-cli": "bin/run.js"