dhti-cli 0.3.3 → 0.5.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.
package/README.md CHANGED
@@ -22,6 +22,9 @@ Generative AI features are built as [LangServe Apps](https://python.langchain.co
22
22
 
23
23
  ### Want to know more?
24
24
 
25
+ *Watch this demo video:*
26
+ [![WATCH DHTI DEMO](https://img.youtube.com/vi/5jFFe3wqKM0/0.jpg)](https://www.youtube.com/watch?v=5jFFe3wqKM0)
27
+
25
28
  Gen AI can transform medicine. But it needs a framework for collaborative research and practice. DHTI is a reference architecture and an implementation for such a framework that integrates an EMR ([OpenMRS](https://openmrs.org/)), :link: Gen AI application server ([LangServe](https://python.langchain.com/v0.2/docs/langserve/)), self-hosted LLMs for privacy ([Ollama](https://ollama.com/)), tools on [MCP server](https://github.com/dermatologist/fhir-mcp-server), vector store for RAG ([redis](https://redis.io/)), monitoring ([LangFuse](https://langfuse.com/)), 🔥 FHIR repository with [CQL](https://nuchange.ca/2025/06/v-llm-in-the-loop-cql-execution-with-unstructured-data-and-fhir-terminology-support.html) support ([HAPI](https://cloud.alphora.com/sandbox/r4/cqm/)) and graph utilities ([Neo4j](https://neo4j.com/)) in one docker-compose! DHTI is inspired by [Bahmni](https://www.bahmni.org/) and **aims to facilitate GenAI adoption and research in areas with low resources.** The MCP server hosts pluggable, agent-invokable tools (FHIR query, summarization, terminology lookup, custom analytics, etc.) that you can extend without modifying core services.
26
29
 
27
30
  The essence of DHTI is *modularity* with an emphasis on *configuration!* It is non-opinionated on LLMs, hyperparameters and pretty much everything. DHTI supports installable Gen AI routines through [LangServe Apps](https://python.langchain.com/docs/langserve/) (which we call :curry: **elixir**) and installable UI elements through [OpenMRS O3](https://o3-docs.openmrs.org/) React container (which we call :shell: **conch**). 🔥 FHIR is used for backend and [CDS-Hooks](https://cds-hooks.org/) for frontend communication, decoupling conches from OpenMRS, making them potentially usable with any health information system. We have a [fork of the cds-hook sandbox](https://github.com/dermatologist/cds-hooks-sandbox/tree/dhti-1) for testing that uses the [order-select](https://cds-hooks.org/hooks/order-select/) hook, utilizing the contentString from the [FHIR CommunicationRequest](https://build.fhir.org/communicationrequest.html) within the [cds-hook context](https://cds-hooks.org/examples/) for user inputs (recommended).
@@ -31,7 +34,7 @@ The essence of DHTI is *modularity* with an emphasis on *configuration!* It is n
31
34
  <img src="https://github.com/dermatologist/openmrs-esm-dhti-template/blob/develop/notes/conch.jpg" />
32
35
  </p>
33
36
 
34
- *[OpenMRS ESM DHTI template](https://github.com/dermatologist/openmrs-esm-dhti-template) + [DHTI elixir template](https://github.com/dermatologist/dhti-elixir-template) together forms a simple but functional EMR chatbot too!* 👉 [Try it out today!](#try-it-out)
37
+ *[OpenMRS ESM DHTI template](https://github.com/dermatologist/openmrs-esm-dhti-template) (frontend) + [DHTI elixir template](https://github.com/dermatologist/dhti-elixir-template) (backend) together forms a simple but functional EMR chatbot too!* 👉 [Try it out today!](#try-it-out)
35
38
 
36
39
  <p align="center">
37
40
  <img src="https://github.com/dermatologist/dhti/blob/develop/notes/cds-hook-sandbox.jpg" />
@@ -66,6 +69,7 @@ The essence of DHTI is *modularity* with an emphasis on *configuration!* It is n
66
69
  * **Quick prototyping**: CLI helps in quick prototyping and testing of Gen AI routines and UI elements.
67
70
  * **Easy to use**: Can be installed in a few minutes.
68
71
  * **Developer friendly**: Copy working files to running containers for testing.
72
+ * **Dry-run mode**: Preview changes before execution with the `--dry-run` flag.
69
73
  * **Dependency Injection**: Dependency injection for models and hyperparameters for configuring elixirs.
70
74
  * **Generate synthetic data**: DHTI supports generating synthetic data for testing.
71
75
  * **CQL support**: [CQL for clinical decision support](https://nuchange.ca/2025/06/v-llm-in-the-loop-cql-execution-with-unstructured-data-and-fhir-terminology-support.html).
@@ -105,7 +109,7 @@ Tools to fine-tune language models for the stack are on our roadmap. We encourag
105
109
  * **EMR**: Built-in EMR, OpenMRS, for patient records.
106
110
  * 👉 [Try it out today!](#try-it-out)
107
111
 
108
- 🌈 *Join us to make the Gen AI equitable and help doctors save lives!*
112
+ *Join us to make the Gen AI equitable and help doctors save lives!*
109
113
 
110
114
  ## :sparkles: Resources
111
115
  * [fhiry](https://github.com/dermatologist/fhiry): FHIR to pandas dataframe for data analytics, AI and ML!
@@ -113,8 +117,8 @@ Tools to fine-tune language models for the stack are on our roadmap. We encourag
113
117
 
114
118
  ## :sparkles: Resources (in Beta)
115
119
  * [dhti-elixir-base](https://github.com/dermatologist/dhti-elixir-base): Base classes for dhti-elixir
116
- * [dhti-elixir-template](https://github.com/dermatologist/dhti-elixir-template): A template for creating new dhti-elixirs.
117
- * [openmrs-esm-dhti-template](https://github.com/dermatologist/openmrs-esm-dhti-template): A conch template for OpenMRS
120
+ * [dhti-elixir-template](https://github.com/dermatologist/dhti-elixir-template): A template for creating new dhti-elixirs & a **simple EMR chatbot backend**.
121
+ * [openmrs-esm-dhti-template](https://github.com/dermatologist/openmrs-esm-dhti-template): A conch template for OpenMRS & a **simple EMR chatbot frontend**.
118
122
  * [fhir-mcp-server](https://github.com/dermatologist/fhir-mcp-server): A MCP server for hosting FHIR-compliant tools.
119
123
 
120
124
  ## :sparkles: Resources (in Alpha)
@@ -123,20 +127,20 @@ Tools to fine-tune language models for the stack are on our roadmap. We encourag
123
127
 
124
128
  ## :sunglasses: Coming soon
125
129
 
126
- * [dhti-elixir-fhire](https://github.com/dermatologist/dhti-elixir-fhire): An elixir for extracting embeddings from FHIR resources for Q&A on patient records.
127
- * [dhti-elixir-fhirs](https://github.com/dermatologist/dhti-elixir-fhirs): An elixir for text to FHIR search query conversion.
130
+ * [dhti-elixir-fhire](https://github.com/dermatologist/dhti-elixir-fhire): An elixir for FHIR embeddings.
128
131
  * [dhti-elixir-upload](https://github.com/dermatologist/dhti-elixir-upload-file): Upload documents to the vector store for clinical knowledgebase and clinical trial matching.
129
- * [openmrs-esm-qa](https://github.com/dermatologist/openmrs-esm-genai): A sample conch for Q&A on patient records using the dhti-elixir-fhire elixir.
130
132
 
131
133
  ## Try it out
132
134
 
133
135
  * You only need [Node.js](https://nodejs.org/) and [Docker](https://www.docker.com/) installed to run this project. Optionally, you can install [Python](https://www.python.org/) if you want to develop new elixirs. We use a fake LLM script for testing purposes, so you don't need an OpenAI key to run this project. It just says "Paris" or "I don't know" to any prompt. You can replace it with any internal or external LLM service later.
134
136
 
137
+ 👉 **If you are in a hurry, just run `./demo.sh` from a terminal (Linux or MacOS) in the root folder to try out the demo.** Windows users can use WSL. You only need [Node.js](https://nodejs.org/) and [Docker](https://www.docker.com/). This script runs all the commands below. Once done, use `npx dhti-cli docker -d` to stop and delete all the docker containers.
138
+
135
139
  * `npx dhti-cli help` to see all available commands.
136
140
 
137
141
  * `npx dhti-cli compose add -m openmrs -m langserve` to add OpenMRS and Langserve elixirs to your docker-compose.yml at ~/dhti. Other available modules: `ollama, langfuse, cqlFhir, redis, neo4j and mcpFhir`. You can read the newly created docker-compose by: `npx dhti-cli compose read`
138
142
 
139
- * `npx dhti-cli elixir install -g https://github.com/dermatologist/dhti-elixir-template.git -n dhti-elixir-template` to install a sample elixir from github. (Optionally) You may configure the hyperparameters in `~/dhti/elixir/app/bootstrap.py`. You can install multiple elixirs.
143
+ * `npx dhti-cli elixir install -g https://github.com/dermatologist/dhti-elixir-template.git -n dhti-elixir-template` to install a sample elixir from github. *(Optional)* You may configure the LLM and hyperparameters in `~/dhti/elixir/app/bootstrap.py`. You can install multiple elixirs.
140
144
 
141
145
  * `npx dhti-cli docker -n yourdockerhandle/genai-test:1.0 -t elixir` to build a docker image for the elixir.
142
146
 
@@ -146,6 +150,10 @@ Tools to fine-tune language models for the stack are on our roadmap. We encourag
146
150
 
147
151
  * `npx dhti-cli docker -u` to start all the docker images in your docker-compose.yml.
148
152
 
153
+ * *(Optional)* **🔍 Dry-run mode**: Add the `--dry-run` flag to any command to preview what changes will be made without actually executing them. For example:
154
+ - `npx dhti-cli compose add -m langserve --dry-run` to preview modules that would be added
155
+ - `npx dhti-cli elixir install -n test-elixir --dry-run` to see what files would be created/modified
156
+
149
157
  ### :clap: Access the Conch in OpenMRS and test the integration
150
158
 
151
159
  * Go to `http://localhost/openmrs/spa/home`
@@ -161,9 +169,9 @@ You will see the text above the textbox.
161
169
 
162
170
  * `npx dhti-cli docker -d` to stop and delete all the docker containers.
163
171
 
164
- Read more in [notes/steps.md](/notes/steps.md). Complete documentation is in progress.
172
+ Read [![Wiki](https://img.shields.io/badge/DHTI-wiki-demo)](https://github.com/dermatologist/dhti/wiki) for more details.
165
173
 
166
- ### The demo uses a template with mock LLM. [Check out how to add real LLM support using Google Gemini.](/notes/add-llm.md)
174
+ ## 👋 The demo uses mock LLM. 👉 [Check out how to add real LLMs and configure them.](https://github.com/dermatologist/dhti/wiki/Configuration)
167
175
 
168
176
  :hugs: **Thank you for trying out DHTI!**
169
177
 
@@ -6,6 +6,7 @@ export default class Compose extends Command {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
9
10
  file: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
11
  module: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
11
12
  };
@@ -1,4 +1,5 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
2
3
  import yaml from 'js-yaml';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
@@ -11,6 +12,10 @@ export default class Compose extends Command {
11
12
  static description = 'Generates a docker-compose.yml file from a list of modules';
12
13
  static examples = ['<%= config.bin %> <%= command.id %>'];
13
14
  static flags = {
15
+ 'dry-run': Flags.boolean({
16
+ default: false,
17
+ description: 'Show what changes would be made without actually making them',
18
+ }),
14
19
  file: Flags.string({
15
20
  char: 'f',
16
21
  default: `${os.homedir()}/dhti/docker-compose.yml`,
@@ -19,7 +24,7 @@ export default class Compose extends Command {
19
24
  // flag with a value (-n, --name=VALUE)
20
25
  module: Flags.string({
21
26
  char: 'm',
22
- description: 'Modules to add from ( langserve, openmrs, ollama, langfuse, cqlFhir, redis, neo4j and mcpFhir)',
27
+ description: 'Modules to add from ( langserve, openmrs, ollama, langfuse, cqlFhir, redis, neo4j, mcpFhir, mcpx and docktor)',
23
28
  multiple: true,
24
29
  }),
25
30
  };
@@ -52,6 +57,8 @@ export default class Compose extends Command {
52
57
  const webui = ['ollama-webui'];
53
58
  const fhir = ['fhir', 'postgres-db'];
54
59
  const mcpFhir = ['mcp-fhir', 'fhir', 'postgres-db'];
60
+ const mcpx = ['mcpx'];
61
+ const docktor = ['mcpx'];
55
62
  const _modules = {
56
63
  cqlFhir,
57
64
  fhir,
@@ -59,6 +66,8 @@ export default class Compose extends Command {
59
66
  langfuse,
60
67
  langserve,
61
68
  mcpFhir,
69
+ mcpx,
70
+ docktor,
62
71
  neo4j,
63
72
  ollama,
64
73
  openmrs,
@@ -71,6 +80,10 @@ export default class Compose extends Command {
71
80
  if (fs.existsSync(flags.file)) {
72
81
  existingData = yaml.load(fs.readFileSync(flags.file, 'utf8'));
73
82
  }
83
+ else if (flags['dry-run']) {
84
+ console.log(chalk.yellow(`[DRY RUN] Would create directory: ${os.homedir()}/dhti`));
85
+ console.log(chalk.yellow(`[DRY RUN] Would create file: ${flags.file}`));
86
+ }
74
87
  else {
75
88
  Compose.init(); // Create the file if it does not exist
76
89
  }
@@ -81,8 +94,14 @@ export default class Compose extends Command {
81
94
  }
82
95
  // Delete flags.file if args.op is reset
83
96
  if (args.op === 'reset') {
84
- fs.unlinkSync(flags.file);
85
- Compose.init(); // Recreate the file
97
+ if (flags['dry-run']) {
98
+ console.log(chalk.yellow(`[DRY RUN] Would delete file: ${flags.file}`));
99
+ console.log(chalk.yellow(`[DRY RUN] Would recreate file: ${flags.file}`));
100
+ }
101
+ else {
102
+ fs.unlinkSync(flags.file);
103
+ Compose.init(); // Recreate the file
104
+ }
86
105
  }
87
106
  // if existing data is not null and arg is delete, remove the modules from the existing data
88
107
  if (Object.keys(existingData.services).length > 0 && args.op === 'delete') {
@@ -91,9 +110,19 @@ export default class Compose extends Command {
91
110
  for (const module of flags.module ?? []) {
92
111
  modulesToDelete = modulesToDelete.concat(_modules[module]);
93
112
  }
94
- for (const module of modulesToDelete ?? []) {
95
- if (existingData.services[module]) {
96
- delete existingData.services[module];
113
+ if (flags['dry-run']) {
114
+ console.log(chalk.yellow('[DRY RUN] Would delete the following modules:'));
115
+ for (const module of modulesToDelete ?? []) {
116
+ if (existingData.services[module]) {
117
+ console.log(chalk.cyan(` - ${module}`));
118
+ }
119
+ }
120
+ }
121
+ else {
122
+ for (const module of modulesToDelete ?? []) {
123
+ if (existingData.services[module]) {
124
+ delete existingData.services[module];
125
+ }
97
126
  }
98
127
  }
99
128
  }
@@ -104,18 +133,35 @@ export default class Compose extends Command {
104
133
  for (const module of flags.module ?? []) {
105
134
  modulesToAdd = modulesToAdd.concat(_modules[module]);
106
135
  }
107
- for (const module of modulesToAdd ?? []) {
108
- existingData.services[module] = masterData.services[module];
136
+ if (flags['dry-run']) {
137
+ console.log(chalk.yellow('[DRY RUN] Would add the following modules:'));
138
+ for (const module of modulesToAdd ?? []) {
139
+ console.log(chalk.cyan(` - ${module}`));
140
+ }
141
+ }
142
+ else {
143
+ for (const module of modulesToAdd ?? []) {
144
+ existingData.services[module] = masterData.services[module];
145
+ }
109
146
  }
110
147
  }
111
148
  // Add all volumes from master data to existing data by default
112
- existingData.volumes = {};
113
- for (const key of Object.keys(masterData.volumes)) {
114
- existingData.volumes[key] = masterData.volumes[key];
149
+ if (!flags['dry-run']) {
150
+ existingData.volumes = {};
151
+ for (const key of Object.keys(masterData.volumes)) {
152
+ existingData.volumes[key] = masterData.volumes[key];
153
+ }
115
154
  }
116
155
  const toWrite = yaml.dump(existingData).replaceAll('null', '');
117
- console.log('Writing file:', toWrite);
118
- fs.writeFileSync(flags.file, toWrite, 'utf8');
156
+ if (flags['dry-run']) {
157
+ console.log(chalk.yellow(`[DRY RUN] Would write to file: ${flags.file}`));
158
+ console.log(chalk.green('[DRY RUN] File content would be:'));
159
+ console.log(toWrite);
160
+ }
161
+ else {
162
+ console.log('Writing file:', toWrite);
163
+ fs.writeFileSync(flags.file, toWrite, 'utf8');
164
+ }
119
165
  }
120
166
  catch (error) {
121
167
  console.error(error);
@@ -9,6 +9,7 @@ export default class Conch extends Command {
9
9
  branch: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
10
  container: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
11
  dev: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
13
  git: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
14
  image: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
15
  name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,4 +1,5 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
2
3
  import { exec } from 'node:child_process';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
@@ -18,6 +19,10 @@ export default class Conch extends Command {
18
19
  description: 'Name of the container to copy the conch to while in dev mode',
19
20
  }),
20
21
  dev: Flags.string({ char: 'd', default: 'none', description: 'Dev folder to install' }),
22
+ 'dry-run': Flags.boolean({
23
+ default: false,
24
+ description: 'Show what changes would be made without actually making them',
25
+ }),
21
26
  git: Flags.string({ char: 'g', default: 'none', description: 'Github repository to install' }),
22
27
  image: Flags.string({
23
28
  char: 'i',
@@ -46,9 +51,17 @@ export default class Conch extends Command {
46
51
  // docker cp ../../openmrs-esm-genai/dist/. dhti-frontend-1:/usr/share/nginx/html/openmrs-esm-genai-1.0.0
47
52
  // docker restart dhti-frontend-1
48
53
  if (args.op === 'dev') {
49
- console.log(`cd ${flags.dev} && yarn build && docker cp dist/. ${flags.container}:/usr/share/nginx/html/${flags.name}-${flags.repoVersion}`);
54
+ const buildCommand = `cd ${flags.dev} && yarn build && docker cp dist/. ${flags.container}:/usr/share/nginx/html/${flags.name}-${flags.repoVersion}`;
55
+ const restartCommand = `docker restart ${flags.container}`;
56
+ if (flags['dry-run']) {
57
+ console.log(chalk.yellow('[DRY RUN] Would execute commands:'));
58
+ console.log(chalk.cyan(` ${buildCommand}`));
59
+ console.log(chalk.cyan(` ${restartCommand}`));
60
+ return;
61
+ }
62
+ console.log(buildCommand);
50
63
  try {
51
- exec(`cd ${flags.dev} && yarn build && docker cp dist/. ${flags.container}:/usr/share/nginx/html/${flags.name}-${flags.repoVersion}`, (error, stdout, stderr) => {
64
+ exec(buildCommand, (error, stdout, stderr) => {
52
65
  if (error) {
53
66
  console.error(`exec error: ${error}`);
54
67
  return;
@@ -56,7 +69,7 @@ export default class Conch extends Command {
56
69
  console.log(`stdout: ${stdout}`);
57
70
  console.error(`stderr: ${stderr}`);
58
71
  });
59
- exec(`docker restart ${flags.container}`, (error, stdout, stderr) => {
72
+ exec(restartCommand, (error, stdout, stderr) => {
60
73
  if (error) {
61
74
  console.error(`exec error: ${error}`);
62
75
  return;
@@ -68,15 +81,53 @@ export default class Conch extends Command {
68
81
  catch (error) {
69
82
  console.log('Error copying conch to container', error);
70
83
  }
84
+ return;
71
85
  }
72
86
  // Create a directory to install the elixir
73
87
  if (!fs.existsSync(`${flags.workdir}/conch`)) {
74
- fs.mkdirSync(`${flags.workdir}/conch`);
88
+ if (flags['dry-run']) {
89
+ console.log(chalk.yellow(`[DRY RUN] Would create directory: ${flags.workdir}/conch`));
90
+ }
91
+ else {
92
+ fs.mkdirSync(`${flags.workdir}/conch`);
93
+ }
94
+ }
95
+ if (flags['dry-run']) {
96
+ console.log(chalk.yellow(`[DRY RUN] Would copy resources from ${RESOURCES_DIR}/spa to ${flags.workdir}/conch`));
97
+ }
98
+ else {
99
+ fs.cpSync(path.join(RESOURCES_DIR, 'spa'), `${flags.workdir}/conch`, { recursive: true });
75
100
  }
76
- fs.cpSync(path.join(RESOURCES_DIR, 'spa'), `${flags.workdir}/conch`, { recursive: true });
77
101
  // Rewrite files
78
102
  const rewrite = () => {
79
103
  flags.name = flags.name ?? 'openmrs-esm-genai';
104
+ if (flags['dry-run']) {
105
+ console.log(chalk.yellow('[DRY RUN] Would update configuration files:'));
106
+ console.log(chalk.cyan(` - ${flags.workdir}/conch/def/importmap.json`));
107
+ if (args.op === 'install') {
108
+ console.log(chalk.green(` Add import: ${flags.name.replace('openmrs-', '@openmrs/')} -> ./${flags.name}-${flags.repoVersion}/${flags.name}.js`));
109
+ }
110
+ if (args.op === 'uninstall') {
111
+ console.log(chalk.green(` Remove import: ${flags.name.replace('openmrs-', '@openmrs/')}`));
112
+ }
113
+ console.log(chalk.cyan(` - ${flags.workdir}/conch/def/spa-assemble-config.json`));
114
+ if (args.op === 'install') {
115
+ console.log(chalk.green(` Add module: ${flags.name.replace('openmrs-', '@openmrs/')} = ${flags.repoVersion}`));
116
+ }
117
+ if (args.op === 'uninstall') {
118
+ console.log(chalk.green(` Remove module: ${flags.name.replace('openmrs-', '@openmrs/')}`));
119
+ }
120
+ console.log(chalk.cyan(` - ${flags.workdir}/conch/Dockerfile`));
121
+ console.log(chalk.green(` Update with conch=${flags.name}, version=${flags.repoVersion}, image=${flags.image}`));
122
+ console.log(chalk.cyan(` - ${flags.workdir}/conch/def/routes.registry.json`));
123
+ if (args.op === 'install') {
124
+ console.log(chalk.green(` Add routes for ${flags.name.replace('openmrs-', '@openmrs/')}`));
125
+ }
126
+ if (args.op === 'uninstall') {
127
+ console.log(chalk.green(` Remove routes for ${flags.name.replace('openmrs-', '@openmrs/')}`));
128
+ }
129
+ return;
130
+ }
80
131
  // Read and process importmap.json
81
132
  const importmap = JSON.parse(fs.readFileSync(`${flags.workdir}/conch/def/importmap.json`, 'utf8'));
82
133
  if (args.op === 'install')
@@ -109,14 +160,23 @@ export default class Conch extends Command {
109
160
  fs.writeFileSync(`${flags.workdir}/conch/def/routes.registry.json`, JSON.stringify(registry, null, 2));
110
161
  };
111
162
  if (flags.git !== 'none') {
163
+ const cloneCommand = `git clone ${flags.git} ${flags.workdir}/conch/${flags.name}`;
164
+ const checkoutCommand = `cd ${flags.workdir}/conch/${flags.name} && git checkout ${flags.branch}`;
165
+ if (flags['dry-run']) {
166
+ console.log(chalk.yellow('[DRY RUN] Would execute git commands:'));
167
+ console.log(chalk.cyan(` ${cloneCommand}`));
168
+ console.log(chalk.cyan(` ${checkoutCommand}`));
169
+ rewrite();
170
+ return;
171
+ }
112
172
  // git clone the repository
113
- exec(`git clone ${flags.git} ${flags.workdir}/conch/${flags.name}`, (error, stdout, stderr) => {
173
+ exec(cloneCommand, (error, stdout, stderr) => {
114
174
  if (error) {
115
175
  console.error(`exec error: ${error}`);
116
176
  return;
117
177
  }
118
178
  // Checkout the branch
119
- exec(`cd ${flags.workdir}/conch/${flags.name} && git checkout ${flags.branch}`, (error, stdout, stderr) => {
179
+ exec(checkoutCommand, (error, stdout, stderr) => {
120
180
  if (error) {
121
181
  console.error(`exec error: ${error}`);
122
182
  return;
@@ -131,8 +191,14 @@ export default class Conch extends Command {
131
191
  }
132
192
  // If flags.dev is not none, copy the dev folder to the conch directory
133
193
  if (flags.dev !== 'none' && args.op !== 'dev') {
134
- fs.cpSync(flags.dev, `${flags.workdir}/conch/${flags.name}`, { recursive: true });
135
- rewrite();
194
+ if (flags['dry-run']) {
195
+ console.log(chalk.yellow(`[DRY RUN] Would copy ${flags.dev} to ${flags.workdir}/conch/${flags.name}`));
196
+ rewrite();
197
+ }
198
+ else {
199
+ fs.cpSync(flags.dev, `${flags.workdir}/conch/${flags.name}`, { recursive: true });
200
+ rewrite();
201
+ }
136
202
  }
137
203
  }
138
204
  }
@@ -6,9 +6,10 @@ export default class Docker extends Command {
6
6
  static description: string;
7
7
  static examples: string[];
8
8
  static flags: {
9
+ container: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
9
10
  down: import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
12
  file: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
- container: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
13
  name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
14
  type: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
15
  up: import("@oclif/core/interfaces").BooleanFlag<boolean>;
@@ -1,4 +1,5 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
2
3
  import yaml from 'js-yaml';
3
4
  import { exec } from 'node:child_process';
4
5
  import fs from 'node:fs';
@@ -11,17 +12,21 @@ export default class Docker extends Command {
11
12
  static description = 'Build a docker project and update docker-compose file';
12
13
  static examples = ['<%= config.bin %> <%= command.id %>'];
13
14
  static flags = {
15
+ container: Flags.string({
16
+ char: 'c',
17
+ default: 'dhti-langserve-1',
18
+ description: 'Name of the container to copy the bootstrap file to while in dev mode',
19
+ }),
14
20
  down: Flags.boolean({ char: 'd', default: false, description: 'Run docker-compose down after building' }),
21
+ 'dry-run': Flags.boolean({
22
+ default: false,
23
+ description: 'Show what changes would be made without actually making them',
24
+ }),
15
25
  file: Flags.string({
16
26
  char: 'f',
17
27
  default: `${os.homedir()}/dhti/docker-compose.yml`,
18
28
  description: 'Full path to the docker compose file to edit or run.',
19
29
  }),
20
- container: Flags.string({
21
- char: 'c',
22
- default: 'dhti-langserve-1',
23
- description: 'Name of the container to copy the bootstrap file to while in dev mode',
24
- }),
25
30
  name: Flags.string({ char: 'n', description: 'Name of the container to build' }),
26
31
  type: Flags.string({ char: 't', default: 'elixir', description: 'Type of the service (elixir/conch)' }),
27
32
  up: Flags.boolean({ char: 'u', default: false, description: 'Run docker-compose up after building' }),
@@ -29,7 +34,13 @@ export default class Docker extends Command {
29
34
  async run() {
30
35
  const { args, flags } = await this.parse(Docker);
31
36
  if (flags.up) {
32
- exec(`docker compose -f ${flags.file} up -d`, (error, stdout, stderr) => {
37
+ const upCommand = `docker compose -f ${flags.file} up -d`;
38
+ if (flags['dry-run']) {
39
+ console.log(chalk.yellow('[DRY RUN] Would execute:'));
40
+ console.log(chalk.cyan(` ${upCommand}`));
41
+ return;
42
+ }
43
+ exec(upCommand, (error, stdout, stderr) => {
33
44
  if (error) {
34
45
  console.error(`exec error: ${error}`);
35
46
  return;
@@ -40,7 +51,13 @@ export default class Docker extends Command {
40
51
  return;
41
52
  }
42
53
  if (flags.down) {
43
- exec(`docker compose -f ${flags.file} down`, (error, stdout, stderr) => {
54
+ const downCommand = `docker compose -f ${flags.file} down`;
55
+ if (flags['dry-run']) {
56
+ console.log(chalk.yellow('[DRY RUN] Would execute:'));
57
+ console.log(chalk.cyan(` ${downCommand}`));
58
+ return;
59
+ }
60
+ exec(downCommand, (error, stdout, stderr) => {
44
61
  if (error) {
45
62
  console.error(`exec error: ${error}`);
46
63
  return;
@@ -60,8 +77,16 @@ export default class Docker extends Command {
60
77
  console.log('Please provide a valid path to bootstrap.py file');
61
78
  this.exit(1);
62
79
  }
80
+ const copyCommand = `docker cp ${flags.file} ${flags.container}:/app/app/bootstrap.py`;
81
+ const restartCommand = `docker restart ${flags.container}`;
82
+ if (flags['dry-run']) {
83
+ console.log(chalk.yellow('[DRY RUN] Would execute:'));
84
+ console.log(chalk.cyan(` ${copyCommand}`));
85
+ console.log(chalk.cyan(` ${restartCommand}`));
86
+ return;
87
+ }
63
88
  // copy -f to container:/app/app/ and only restart after copy completes
64
- exec(`docker cp ${flags.file} ${flags.container}:/app/app/bootstrap.py`, (error, stdout, stderr) => {
89
+ exec(copyCommand, (error, stdout, stderr) => {
65
90
  if (error) {
66
91
  console.error(`exec error: ${error}`);
67
92
  return;
@@ -69,7 +94,7 @@ export default class Docker extends Command {
69
94
  console.log(`stdout: ${stdout}`);
70
95
  console.error(`stderr: ${stderr}`);
71
96
  // restart the container only after copy completes
72
- exec(`docker restart ${flags.container}`, (restartError, restartStdout, restartStderr) => {
97
+ exec(restartCommand, (restartError, restartStdout, restartStderr) => {
73
98
  if (restartError) {
74
99
  console.error(`exec error: ${restartError}`);
75
100
  return;
@@ -81,8 +106,23 @@ export default class Docker extends Command {
81
106
  return;
82
107
  }
83
108
  // cd to path, docker build tag with name
109
+ const buildCommand = `cd ${args.path}/${flags.type} && docker build -t ${flags.name} . > /dev/null 2>&1`;
110
+ if (flags['dry-run']) {
111
+ console.log(chalk.yellow('[DRY RUN] Would execute:'));
112
+ console.log(chalk.cyan(` ${buildCommand}`));
113
+ console.log(chalk.yellow(`[DRY RUN] Would update docker-compose file: ${flags.file}`));
114
+ if (flags.type === 'elixir') {
115
+ console.log(chalk.green(` Set langserve.image = ${flags.name}`));
116
+ console.log(chalk.green(` Set langserve.pull_policy = if_not_present`));
117
+ }
118
+ else {
119
+ console.log(chalk.green(` Set frontend.image = ${flags.name}`));
120
+ console.log(chalk.green(` Set frontend.pull_policy = if_not_present`));
121
+ }
122
+ return;
123
+ }
84
124
  const spinner = ora('Running docker build ..').start();
85
- exec(`cd ${args.path}/${flags.type} && docker build -t ${flags.name} . > /dev/null 2>&1`, (error, stdout, stderr) => {
125
+ exec(buildCommand, (error, stdout, stderr) => {
86
126
  if (error) {
87
127
  spinner.fail('Docker build failed');
88
128
  console.error(`exec error: ${error}`);
@@ -0,0 +1,18 @@
1
+ import { Command } from '@oclif/core';
2
+ export default class Docktor extends Command {
3
+ static args: {
4
+ op: import("@oclif/core/interfaces").Arg<string, Record<string, unknown>>;
5
+ name: import("@oclif/core/interfaces").Arg<string | undefined, Record<string, unknown>>;
6
+ };
7
+ static description: string;
8
+ static examples: string[];
9
+ static flags: {
10
+ container: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
+ environment: import("@oclif/core/interfaces").OptionFlag<string[] | undefined, import("@oclif/core/interfaces").CustomOptions>;
12
+ image: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
13
+ 'model-path': import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
14
+ workdir: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
15
+ };
16
+ private restartMcpxContainer;
17
+ run(): Promise<void>;
18
+ }
@@ -0,0 +1,143 @@
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ export default class Docktor extends Command {
7
+ static args = {
8
+ op: Args.string({ description: 'Operation to perform (install, remove, restart, list)', required: true }),
9
+ name: Args.string({ description: 'Name of the inference pipeline (e.g., skin-cancer-classifier)', required: false }),
10
+ };
11
+ static description = 'Manage inference pipelines for MCPX';
12
+ static examples = [
13
+ '<%= config.bin %> <%= command.id %> install my-pipeline --image my-image:latest --model-path ./models',
14
+ '<%= config.bin %> <%= command.id %> remove my-pipeline',
15
+ '<%= config.bin %> <%= command.id %> list',
16
+ ];
17
+ static flags = {
18
+ container: Flags.string({
19
+ char: 'c',
20
+ default: 'dhti-mcpx-1',
21
+ description: 'Docker container name for MCPX (use docker ps to find the correct name)',
22
+ }),
23
+ environment: Flags.string({
24
+ char: 'e',
25
+ multiple: true,
26
+ description: 'Environment variables to pass to docker (format: VAR=value)',
27
+ }),
28
+ image: Flags.string({ char: 'i', description: 'Docker image for the inference pipeline (required for install)' }),
29
+ 'model-path': Flags.string({
30
+ char: 'm',
31
+ default: '/lunar/packages/mcpx-server/config',
32
+ description: 'Local path to the model directory (optional for install)',
33
+ }),
34
+ workdir: Flags.string({
35
+ char: 'w',
36
+ default: `${os.homedir()}/dhti`,
37
+ description: 'Working directory for MCPX config',
38
+ }),
39
+ };
40
+ async restartMcpxContainer(mcpxConfigPath, containerName) {
41
+ try {
42
+ const { execSync } = await import('node:child_process');
43
+ execSync(`docker cp ${mcpxConfigPath} ${containerName}:/lunar/packages/mcpx-server/`);
44
+ this.log(chalk.green('Copied mcp.json to container: /lunar/packages/mcpx-server/config/mcp.json'));
45
+ execSync(`docker restart ${containerName}`);
46
+ this.log(chalk.green(`Restarted ${containerName} container.`));
47
+ }
48
+ catch (err) {
49
+ this.log(chalk.red(`Failed to copy config or restart container '${containerName}'. Please check Docker status and container name.`));
50
+ }
51
+ }
52
+ async run() {
53
+ const { args, flags } = await this.parse(Docktor);
54
+ const mcpxConfigPath = path.join(flags.workdir, 'config');
55
+ const mcpJsonPath = path.join(mcpxConfigPath, 'mcp.json');
56
+ // Ensure config directory exists
57
+ if (!fs.existsSync(mcpxConfigPath)) {
58
+ fs.mkdirSync(mcpxConfigPath, { recursive: true });
59
+ }
60
+ // Ensure mcp.json exists
61
+ if (!fs.existsSync(mcpJsonPath)) {
62
+ fs.writeFileSync(mcpJsonPath, JSON.stringify({ mcpServers: {} }, null, 2));
63
+ }
64
+ let mcpConfig = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8'));
65
+ // Ensure mcpServers exists
66
+ if (!mcpConfig.mcpServers) {
67
+ mcpConfig.mcpServers = {};
68
+ }
69
+ if (args.op === 'install') {
70
+ if (!args.name) {
71
+ this.error('Name is required for install operation');
72
+ }
73
+ if (!flags.image) {
74
+ this.error('Image is required for install operation');
75
+ }
76
+ const binds = [];
77
+ const envVars = [];
78
+ if (flags['model-path']) {
79
+ const absModelPath = path.resolve(flags['model-path']);
80
+ binds.push(`${absModelPath}:/model`);
81
+ }
82
+ if (flags.environment && flags.environment.length > 0) {
83
+ const invalidEnvVars = flags.environment.filter((e) => {
84
+ const idx = e.indexOf('=');
85
+ return idx <= 0 || idx === e.length - 1;
86
+ });
87
+ if (invalidEnvVars.length > 0) {
88
+ this.error(`Invalid environment variable format. Expected 'NAME=value'. Invalid entries: ${invalidEnvVars.join(', ')}`);
89
+ }
90
+ envVars.push(...flags.environment);
91
+ }
92
+ // Add socket mounting for docker tools if needed, but primarily we want the container to run as a server
93
+ // MCPX handles the running of the docker container.
94
+ // We need to configure it in mcp.json so MCPX picks it up.
95
+ // Based on MCP std, docker servers are defined with `docker` command.
96
+ // Add (merge) new server into existing mcpServers
97
+ mcpConfig.mcpServers[args.name] = {
98
+ command: 'docker',
99
+ args: [
100
+ 'run',
101
+ '-i',
102
+ '--rm',
103
+ ...binds.flatMap((b) => ['-v', b]),
104
+ ...envVars.flatMap((e) => ['-e', e]),
105
+ flags.image,
106
+ ],
107
+ };
108
+ // Write back the updated config (preserving all other properties and existing servers)
109
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 2));
110
+ this.log(chalk.green(`Inference pipeline '${args.name}' added to MCPX config.`));
111
+ // Copy only mcp.json to container and restart
112
+ await this.restartMcpxContainer(mcpxConfigPath, flags.container);
113
+ }
114
+ else if (args.op === 'remove') {
115
+ if (!args.name) {
116
+ this.error('Name is required for remove operation');
117
+ }
118
+ if (mcpConfig.mcpServers && mcpConfig.mcpServers[args.name]) {
119
+ delete mcpConfig.mcpServers[args.name];
120
+ // Write back the updated config (preserving all other properties and remaining servers)
121
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(mcpConfig, null, 2));
122
+ this.log(chalk.green(`Inference pipeline '${args.name}' removed from MCPX config.`));
123
+ this.log(chalk.yellow('Please restart the MCPX container to apply changes: dhti-cli docktor restart'));
124
+ }
125
+ else {
126
+ this.log(chalk.yellow(`Inference pipeline '${args.name}' not found.`));
127
+ }
128
+ }
129
+ else if (args.op === 'restart') {
130
+ await this.restartMcpxContainer(mcpxConfigPath, flags.container);
131
+ }
132
+ else if (args.op === 'list') {
133
+ this.log(chalk.blue('Installed Inference Pipelines:'));
134
+ for (const [name, config] of Object.entries(mcpConfig.mcpServers)) {
135
+ const argsList = Array.isArray(config.args) ? config.args.join(' ') : '';
136
+ this.log(`- ${name}: ${argsList}`);
137
+ }
138
+ }
139
+ else {
140
+ this.error(`Unknown operation: ${args.op}`);
141
+ }
142
+ }
143
+ }
@@ -9,6 +9,7 @@ export default class Elixir extends Command {
9
9
  branch: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
10
10
  container: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
11
11
  dev: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
12
13
  git: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
13
14
  name: import("@oclif/core/interfaces").OptionFlag<string | undefined, import("@oclif/core/interfaces").CustomOptions>;
14
15
  pypi: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,4 +1,5 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
2
3
  import { exec } from 'node:child_process';
3
4
  import fs from 'node:fs';
4
5
  import os from 'node:os';
@@ -18,6 +19,10 @@ export default class Elixir extends Command {
18
19
  description: 'Name of the container to copy the elixir to while in dev mode',
19
20
  }),
20
21
  dev: Flags.string({ char: 'd', default: 'none', description: 'Dev folder to install' }),
22
+ 'dry-run': Flags.boolean({
23
+ default: false,
24
+ description: 'Show what changes would be made without actually making them',
25
+ }),
21
26
  git: Flags.string({ char: 'g', default: 'none', description: 'Github repository to install' }),
22
27
  name: Flags.string({ char: 'n', description: 'Name of the elixir' }),
23
28
  pypi: Flags.string({
@@ -47,9 +52,17 @@ export default class Elixir extends Command {
47
52
  // if arg is dev then copy to docker as below
48
53
  // docker restart dhti-langserve-1
49
54
  if (args.op === 'dev') {
50
- console.log(`cd ${flags.dev} && docker cp src/${expoName}/. ${flags.container}:/app/.venv/lib/python3.12/site-packages/${expoName}`);
55
+ const devCommand = `cd ${flags.dev} && docker cp src/${expoName}/. ${flags.container}:/app/.venv/lib/python3.12/site-packages/${expoName}`;
56
+ const restartCommand = `docker restart ${flags.container}`;
57
+ if (flags['dry-run']) {
58
+ console.log(chalk.yellow('[DRY RUN] Would execute commands:'));
59
+ console.log(chalk.cyan(` ${devCommand}`));
60
+ console.log(chalk.cyan(` ${restartCommand}`));
61
+ return;
62
+ }
63
+ console.log(devCommand);
51
64
  try {
52
- exec(`cd ${flags.dev} && docker cp src/${expoName}/. ${flags.container}:/app/.venv/lib/python3.12/site-packages/${expoName}`, (error, stdout, stderr) => {
65
+ exec(devCommand, (error, stdout, stderr) => {
53
66
  if (error) {
54
67
  console.error(`exec error: ${error}`);
55
68
  return;
@@ -57,7 +70,7 @@ export default class Elixir extends Command {
57
70
  console.log(`stdout: ${stdout}`);
58
71
  console.error(`stderr: ${stderr}`);
59
72
  });
60
- exec(`docker restart ${flags.container}`, (error, stdout, stderr) => {
73
+ exec(restartCommand, (error, stdout, stderr) => {
61
74
  if (error) {
62
75
  console.error(`exec error: ${error}`);
63
76
  return;
@@ -69,23 +82,45 @@ export default class Elixir extends Command {
69
82
  catch (error) {
70
83
  console.log('Error copying conch to container', error);
71
84
  }
85
+ return;
72
86
  }
73
87
  // Create a directory to install the elixir
74
88
  if (!fs.existsSync(`${flags.workdir}/elixir`)) {
75
- fs.mkdirSync(`${flags.workdir}/elixir`);
89
+ if (flags['dry-run']) {
90
+ console.log(chalk.yellow(`[DRY RUN] Would create directory: ${flags.workdir}/elixir`));
91
+ }
92
+ else {
93
+ fs.mkdirSync(`${flags.workdir}/elixir`);
94
+ }
95
+ }
96
+ if (flags['dry-run']) {
97
+ console.log(chalk.yellow(`[DRY RUN] Would copy resources from ${RESOURCES_DIR}/genai to ${flags.workdir}/elixir`));
98
+ }
99
+ else {
100
+ fs.cpSync(path.join(RESOURCES_DIR, 'genai'), `${flags.workdir}/elixir`, { recursive: true });
76
101
  }
77
- fs.cpSync(path.join(RESOURCES_DIR, 'genai'), `${flags.workdir}/elixir`, { recursive: true });
78
102
  // if whl is not none, copy the whl file to thee whl directory
79
103
  if (flags.whl !== 'none') {
80
104
  if (!fs.existsSync(`${flags.workdir}/elixir/whl/`)) {
81
- fs.mkdirSync(`${flags.workdir}/whl/`);
105
+ if (flags['dry-run']) {
106
+ console.log(chalk.yellow(`[DRY RUN] Would create directory: ${flags.workdir}/whl/`));
107
+ }
108
+ else {
109
+ fs.mkdirSync(`${flags.workdir}/whl/`);
110
+ }
111
+ }
112
+ if (flags['dry-run']) {
113
+ console.log(chalk.yellow(`[DRY RUN] Would copy ${flags.whl} to ${flags.workdir}/elixir/whl/${path.basename(flags.whl)}`));
114
+ console.log(chalk.cyan('[DRY RUN] Installing elixir from whl file. Please modify boostrap.py file if needed'));
115
+ }
116
+ else {
117
+ fs.cpSync(flags.whl, `${flags.workdir}/elixir/whl/${path.basename(flags.whl)}`);
118
+ console.log('Installing elixir from whl file. Please modify boostrap.py file if needed');
82
119
  }
83
- fs.cpSync(flags.whl, `${flags.workdir}/elixir/whl/${path.basename(flags.whl)}`);
84
- console.log('Installing elixir from whl file. Please modify boostrap.py file if needed');
85
120
  }
86
121
  // Install the elixir from git adding to the pyproject.toml file
87
- let pyproject = fs.readFileSync(`${flags.workdir}/elixir/pyproject.toml`, 'utf8');
88
- const originalServer = fs.readFileSync(`${flags.workdir}/elixir/app/server.py`, 'utf8');
122
+ let pyproject = flags['dry-run'] ? '' : fs.readFileSync(`${flags.workdir}/elixir/pyproject.toml`, 'utf8');
123
+ const originalServer = flags['dry-run'] ? '' : fs.readFileSync(`${flags.workdir}/elixir/app/server.py`, 'utf8');
89
124
  let lineToAdd = '';
90
125
  if (flags.whl !== 'none') {
91
126
  lineToAdd = `${flags.name} = { file = "whl/${path.basename(flags.whl)}" }`;
@@ -96,8 +131,10 @@ export default class Elixir extends Command {
96
131
  if (flags.pypi !== 'none') {
97
132
  lineToAdd = flags.pypi;
98
133
  }
99
- pyproject = pyproject.replace('dependencies = [', `dependencies = [\n"${flags.name}",`);
100
- pyproject = pyproject.replace('[tool.uv.sources]', `[tool.uv.sources]\n${lineToAdd}\n`);
134
+ if (!flags['dry-run']) {
135
+ pyproject = pyproject.replace('dependencies = [', `dependencies = [\n"${flags.name}",`);
136
+ pyproject = pyproject.replace('[tool.uv.sources]', `[tool.uv.sources]\n${lineToAdd}\n`);
137
+ }
101
138
  const newPyproject = pyproject;
102
139
  // Add the elixir import and bootstrap to the server.py file
103
140
  let CliImport = `from ${expoName}.bootstrap import bootstrap as ${expoName}_bootstrap\n`;
@@ -108,27 +145,49 @@ ${expoName}_chain = ${expoName}_chain_class().get_chain_as_langchain_tool()
108
145
  ${expoName}_mcp_tool = ${expoName}_chain_class().get_chain_as_mcp_tool
109
146
  mcp_server.add_tool(${expoName}_mcp_tool) # type: ignore
110
147
  `;
111
- const newCliImport = fs
112
- .readFileSync(`${flags.workdir}/elixir/app/server.py`, 'utf8')
113
- .replace('# DHTI_CLI_IMPORT', `#DHTI_CLI_IMPORT\n${CliImport}`);
148
+ let newCliImport = '';
149
+ if (!flags['dry-run']) {
150
+ newCliImport = fs
151
+ .readFileSync(`${flags.workdir}/elixir/app/server.py`, 'utf8')
152
+ .replace('# DHTI_CLI_IMPORT', `#DHTI_CLI_IMPORT\n${CliImport}`);
153
+ }
114
154
  const langfuseRoute = `add_routes(app, ${expoName}_chain.with_config(config), path="/langserve/${expoName}")`;
115
- const newLangfuseRoute = newCliImport.replace('# DHTI_LANGFUSE_ROUTE', `#DHTI_LANGFUSE_ROUTE\n ${langfuseRoute}`);
155
+ const newLangfuseRoute = flags['dry-run'] ? '' : newCliImport.replace('# DHTI_LANGFUSE_ROUTE', `#DHTI_LANGFUSE_ROUTE\n ${langfuseRoute}`);
116
156
  const normalRoute = `add_routes(app, ${expoName}_chain, path="/langserve/${expoName}")`;
117
- const newNormalRoute = newLangfuseRoute.replace('# DHTI_NORMAL_ROUTE', `#DHTI_NORMAL_ROUTE\n ${normalRoute}`);
157
+ const newNormalRoute = flags['dry-run'] ? '' : newLangfuseRoute.replace('# DHTI_NORMAL_ROUTE', `#DHTI_NORMAL_ROUTE\n ${normalRoute}`);
118
158
  const commonRoutes = `\nadd_invokes(app, path="/langserve/${expoName}")\nadd_services(app, path="/langserve/${expoName}")`;
119
- const finalRoute = newNormalRoute.replace('# DHTI_COMMON_ROUTE', `#DHTI_COMMON_ROUTES${commonRoutes}`);
159
+ const finalRoute = flags['dry-run'] ? '' : newNormalRoute.replace('# DHTI_COMMON_ROUTE', `#DHTI_COMMON_ROUTES${commonRoutes}`);
120
160
  // if args.op === install, add the line to the pyproject.toml file
121
161
  if (args.op === 'install') {
122
- fs.writeFileSync(`${flags.workdir}/elixir/pyproject.toml`, newPyproject);
123
- fs.writeFileSync(`${flags.workdir}/elixir/app/server.py`, finalRoute);
162
+ if (flags['dry-run']) {
163
+ console.log(chalk.yellow('[DRY RUN] Would update files:'));
164
+ console.log(chalk.cyan(` - ${flags.workdir}/elixir/pyproject.toml`));
165
+ console.log(chalk.green(` Add dependency: "${flags.name}"`));
166
+ console.log(chalk.green(` Add source: ${lineToAdd}`));
167
+ console.log(chalk.cyan(` - ${flags.workdir}/elixir/app/server.py`));
168
+ console.log(chalk.green(` Add import and routes for ${expoName}`));
169
+ }
170
+ else {
171
+ fs.writeFileSync(`${flags.workdir}/elixir/pyproject.toml`, newPyproject);
172
+ fs.writeFileSync(`${flags.workdir}/elixir/app/server.py`, finalRoute);
173
+ }
124
174
  }
125
175
  if (args.op === 'uninstall') {
126
- // if args.op === uninstall, remove the line from the pyproject.toml file
127
- fs.writeFileSync(`${flags.workdir}/elixir/pyproject.toml`, pyproject.replace(lineToAdd, ''));
128
- let newServer = originalServer.replace(CliImport, '');
129
- newServer = newServer.replace(langfuseRoute, '');
130
- newServer = newServer.replace(normalRoute, '');
131
- fs.writeFileSync(`${flags.workdir}/elixir/app/server.py`, newServer);
176
+ if (flags['dry-run']) {
177
+ console.log(chalk.yellow('[DRY RUN] Would update files:'));
178
+ console.log(chalk.cyan(` - ${flags.workdir}/elixir/pyproject.toml`));
179
+ console.log(chalk.green(` Remove source: ${lineToAdd}`));
180
+ console.log(chalk.cyan(` - ${flags.workdir}/elixir/app/server.py`));
181
+ console.log(chalk.green(` Remove import and routes for ${expoName}`));
182
+ }
183
+ else {
184
+ // if args.op === uninstall, remove the line from the pyproject.toml file
185
+ fs.writeFileSync(`${flags.workdir}/elixir/pyproject.toml`, pyproject.replace(lineToAdd, ''));
186
+ let newServer = originalServer.replace(CliImport, '');
187
+ newServer = newServer.replace(langfuseRoute, '');
188
+ newServer = newServer.replace(normalRoute, '');
189
+ fs.writeFileSync(`${flags.workdir}/elixir/app/server.py`, newServer);
190
+ }
132
191
  }
133
192
  }
134
193
  }
@@ -5,5 +5,8 @@ export default class Mimic extends Command {
5
5
  };
6
6
  static description: string;
7
7
  static examples: string[];
8
+ static flags: {
9
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
10
+ };
8
11
  run(): Promise<void>;
9
12
  }
@@ -1,10 +1,17 @@
1
- import { Args, Command } from '@oclif/core';
1
+ import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
2
3
  export default class Mimic extends Command {
3
4
  static args = {
4
5
  server: Args.string({ default: 'http://localhost/fhir/$import', description: 'Server URL to submit' }), // object with input, instruction (rationale in distillation), output
5
6
  };
6
7
  static description = 'Submit a FHIR request to a server';
7
8
  static examples = ['<%= config.bin %> <%= command.id %>'];
9
+ static flags = {
10
+ 'dry-run': Flags.boolean({
11
+ default: false,
12
+ description: 'Show what changes would be made without actually making them',
13
+ }),
14
+ };
8
15
  async run() {
9
16
  const { args, flags } = await this.parse(Mimic);
10
17
  const mimic_request = `{
@@ -140,6 +147,15 @@ export default class Mimic extends Command {
140
147
  } ]
141
148
 
142
149
  }`;
150
+ if (flags['dry-run']) {
151
+ console.log(chalk.yellow(`[DRY RUN] Would send POST request to: ${args.server}`));
152
+ console.log(chalk.cyan('[DRY RUN] Request headers:'));
153
+ console.log(chalk.green(' Content-Type: application/fhir+json'));
154
+ console.log(chalk.green(' Prefer: respond-async'));
155
+ console.log(chalk.cyan('[DRY RUN] Request body:'));
156
+ console.log(mimic_request);
157
+ return;
158
+ }
143
159
  // send a POST request to the server with the mimic_request body
144
160
  const response = await fetch(args.server, {
145
161
  body: mimic_request,
@@ -151,7 +167,6 @@ export default class Mimic extends Command {
151
167
  });
152
168
  if (!response.ok) {
153
169
  console.error(`Error: ${response.status} ${response.statusText}`);
154
- return;
155
170
  }
156
171
  }
157
172
  }
@@ -8,6 +8,7 @@ export default class Synthetic extends Command {
8
8
  static description: string;
9
9
  static examples: string[];
10
10
  static flags: {
11
+ 'dry-run': import("@oclif/core/interfaces").BooleanFlag<boolean>;
11
12
  inputField: import("@oclif/core/interfaces").OptionFlag<string, import("@oclif/core/interfaces").CustomOptions>;
12
13
  maxCycles: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
13
14
  maxRecords: import("@oclif/core/interfaces").OptionFlag<number, import("@oclif/core/interfaces").CustomOptions>;
@@ -1,4 +1,5 @@
1
1
  import { Args, Command, Flags } from '@oclif/core';
2
+ import chalk from 'chalk';
2
3
  import fs from 'node:fs';
3
4
  import bootstrap from '../utils/bootstrap.js';
4
5
  import { ChainService } from '../utils/chain.js';
@@ -13,6 +14,10 @@ export default class Synthetic extends Command {
13
14
  '<%= config.bin %> <%= command.id %>',
14
15
  ];
15
16
  static flags = {
17
+ 'dry-run': Flags.boolean({
18
+ default: false,
19
+ description: 'Show what changes would be made without actually making them',
20
+ }),
16
21
  inputField: Flags.string({ char: 'i', default: 'input', description: 'Input field to use', options: ['input', 'instruction', 'output'] }),
17
22
  maxCycles: Flags.integer({ char: 'm', default: 0, description: 'Maximum number of cycles to run' }),
18
23
  maxRecords: Flags.integer({ char: 'r', default: 10, description: 'Maximum number of records to generate' }),
@@ -24,13 +29,35 @@ export default class Synthetic extends Command {
24
29
  // read prompt file if provided
25
30
  if (args.prompt)
26
31
  prompt = fs.readFileSync(args.prompt ?? '', 'utf8');
27
- const container = await bootstrap();
28
- const chain = new ChainService(container);
29
32
  // if no output file, exit with error
30
33
  if (!args.output) {
31
34
  console.log("Please provide an output file");
32
35
  this.exit(1);
33
36
  }
37
+ if (flags['dry-run']) {
38
+ console.log(chalk.yellow('[DRY RUN] Synthetic data generation simulation'));
39
+ console.log(chalk.cyan(` Output file: ${args.output}`));
40
+ console.log(chalk.cyan(` Max records: ${flags.maxRecords}`));
41
+ if (flags.maxCycles) {
42
+ console.log(chalk.cyan(` Max cycles: ${flags.maxCycles}`));
43
+ console.log(chalk.cyan(` Output field: ${flags.outputField}`));
44
+ console.log(chalk.green('[DRY RUN] Would generate synthetic data in batches using LLM'));
45
+ console.log(chalk.green(`[DRY RUN] Would write ${flags.maxRecords} records to ${args.output}`));
46
+ }
47
+ else {
48
+ console.log(chalk.cyan(` Input file: ${args.input}`));
49
+ console.log(chalk.cyan(` Input field: ${flags.inputField}`));
50
+ console.log(chalk.cyan(` Output field: ${flags.outputField}`));
51
+ if (args.prompt) {
52
+ console.log(chalk.cyan(` Prompt file: ${args.prompt}`));
53
+ }
54
+ console.log(chalk.green('[DRY RUN] Would process input file records using LLM'));
55
+ console.log(chalk.green(`[DRY RUN] Would write processed records to ${args.output}`));
56
+ }
57
+ return;
58
+ }
59
+ const container = await bootstrap();
60
+ const chain = new ChainService(container);
34
61
  if (flags.maxCycles) { // No input file, can process in batches
35
62
  const input = {
36
63
  input: prompt,
@@ -10,7 +10,7 @@ services:
10
10
  - backend
11
11
  ports:
12
12
  - "80:80"
13
- - "9000:80"
13
+ - "9001:80"
14
14
 
15
15
 
16
16
  frontend:
@@ -133,7 +133,7 @@ services:
133
133
  - "hapi.fhir.enforce_referential_integrity_on_delete=false"
134
134
 
135
135
  mcp-fhir:
136
- image: beapen/fhir-mcp-server:1.0
136
+ image: beapen/fhir-mcp-server:4.0
137
137
  ports:
138
138
  - 8006:8000
139
139
  restart: "unless-stopped"
@@ -225,6 +225,23 @@ services:
225
225
  - spring.neo4j.authentication.username=neo4j
226
226
  - spring.neo4j.authentication.password=password
227
227
 
228
+ mcpx:
229
+ image: us-central1-docker.pkg.dev/prj-common-442813/mcpx/mcpx:latest
230
+ ports:
231
+ - "8000:8000"
232
+ - "9000:9000"
233
+ - "5173:5173"
234
+ - "3000:3000"
235
+ # environment:
236
+ # - MCPX_PORT=9000
237
+ # - MCPX_SERVER_URL="http://10.0.0.211:9000"
238
+ # - VITE_MCPX_SERVER_PORT=9000
239
+ # - VITE_MCPX_SERVER_URL="http://10.0.0.211:9000"
240
+ restart: unless-stopped
241
+ volumes:
242
+ - mcpx-config:/lunar/packages/mcpx-server/config
243
+ privileged: true
244
+
228
245
 
229
246
  volumes:
230
247
  openmrs-data: ~
@@ -235,4 +252,5 @@ volumes:
235
252
  neo4j-db: ~
236
253
  ollama-code: ~
237
254
  ollama-root: ~
238
- ollama-webui: ~
255
+ ollama-webui: ~
256
+ mcpx-config: ~
@@ -13,6 +13,12 @@
13
13
  "<%= config.bin %> <%= command.id %>"
14
14
  ],
15
15
  "flags": {
16
+ "dry-run": {
17
+ "description": "Show what changes would be made without actually making them",
18
+ "name": "dry-run",
19
+ "allowNo": false,
20
+ "type": "boolean"
21
+ },
16
22
  "file": {
17
23
  "char": "f",
18
24
  "description": "Full path to the docker compose file to read from. Creates if it does not exist",
@@ -24,7 +30,7 @@
24
30
  },
25
31
  "module": {
26
32
  "char": "m",
27
- "description": "Modules to add from ( langserve, openmrs, ollama, langfuse, cqlFhir, redis, neo4j and mcpFhir)",
33
+ "description": "Modules to add from ( langserve, openmrs, ollama, langfuse, cqlFhir, redis, neo4j, mcpFhir, mcpx and docktor)",
28
34
  "name": "module",
29
35
  "hasDynamicHelp": false,
30
36
  "multiple": true,
@@ -86,6 +92,12 @@
86
92
  "multiple": false,
87
93
  "type": "option"
88
94
  },
95
+ "dry-run": {
96
+ "description": "Show what changes would be made without actually making them",
97
+ "name": "dry-run",
98
+ "allowNo": false,
99
+ "type": "boolean"
100
+ },
89
101
  "git": {
90
102
  "char": "g",
91
103
  "description": "Github repository to install",
@@ -160,6 +172,15 @@
160
172
  "<%= config.bin %> <%= command.id %>"
161
173
  ],
162
174
  "flags": {
175
+ "container": {
176
+ "char": "c",
177
+ "description": "Name of the container to copy the bootstrap file to while in dev mode",
178
+ "name": "container",
179
+ "default": "dhti-langserve-1",
180
+ "hasDynamicHelp": false,
181
+ "multiple": false,
182
+ "type": "option"
183
+ },
163
184
  "down": {
164
185
  "char": "d",
165
186
  "description": "Run docker-compose down after building",
@@ -167,6 +188,12 @@
167
188
  "allowNo": false,
168
189
  "type": "boolean"
169
190
  },
191
+ "dry-run": {
192
+ "description": "Show what changes would be made without actually making them",
193
+ "name": "dry-run",
194
+ "allowNo": false,
195
+ "type": "boolean"
196
+ },
170
197
  "file": {
171
198
  "char": "f",
172
199
  "description": "Full path to the docker compose file to edit or run.",
@@ -176,15 +203,6 @@
176
203
  "multiple": false,
177
204
  "type": "option"
178
205
  },
179
- "container": {
180
- "char": "c",
181
- "description": "Name of the container to copy the bootstrap file to while in dev mode",
182
- "name": "container",
183
- "default": "dhti-langserve-1",
184
- "hasDynamicHelp": false,
185
- "multiple": false,
186
- "type": "option"
187
- },
188
206
  "name": {
189
207
  "char": "n",
190
208
  "description": "Name of the container to build",
@@ -225,6 +243,86 @@
225
243
  "docker.js"
226
244
  ]
227
245
  },
246
+ "docktor": {
247
+ "aliases": [],
248
+ "args": {
249
+ "op": {
250
+ "description": "Operation to perform (install, remove, restart, list)",
251
+ "name": "op",
252
+ "required": true
253
+ },
254
+ "name": {
255
+ "description": "Name of the inference pipeline (e.g., skin-cancer-classifier)",
256
+ "name": "name",
257
+ "required": false
258
+ }
259
+ },
260
+ "description": "Manage inference pipelines for MCPX",
261
+ "examples": [
262
+ "<%= config.bin %> <%= command.id %> install my-pipeline --image my-image:latest --model-path ./models",
263
+ "<%= config.bin %> <%= command.id %> remove my-pipeline",
264
+ "<%= config.bin %> <%= command.id %> list"
265
+ ],
266
+ "flags": {
267
+ "container": {
268
+ "char": "c",
269
+ "description": "Docker container name for MCPX (use docker ps to find the correct name)",
270
+ "name": "container",
271
+ "default": "dhti-mcpx-1",
272
+ "hasDynamicHelp": false,
273
+ "multiple": false,
274
+ "type": "option"
275
+ },
276
+ "environment": {
277
+ "char": "e",
278
+ "description": "Environment variables to pass to docker (format: VAR=value)",
279
+ "name": "environment",
280
+ "hasDynamicHelp": false,
281
+ "multiple": true,
282
+ "type": "option"
283
+ },
284
+ "image": {
285
+ "char": "i",
286
+ "description": "Docker image for the inference pipeline (required for install)",
287
+ "name": "image",
288
+ "hasDynamicHelp": false,
289
+ "multiple": false,
290
+ "type": "option"
291
+ },
292
+ "model-path": {
293
+ "char": "m",
294
+ "description": "Local path to the model directory (optional for install)",
295
+ "name": "model-path",
296
+ "default": "/lunar/packages/mcpx-server/config",
297
+ "hasDynamicHelp": false,
298
+ "multiple": false,
299
+ "type": "option"
300
+ },
301
+ "workdir": {
302
+ "char": "w",
303
+ "description": "Working directory for MCPX config",
304
+ "name": "workdir",
305
+ "default": "/home/runner/dhti",
306
+ "hasDynamicHelp": false,
307
+ "multiple": false,
308
+ "type": "option"
309
+ }
310
+ },
311
+ "hasDynamicHelp": false,
312
+ "hiddenAliases": [],
313
+ "id": "docktor",
314
+ "pluginAlias": "dhti-cli",
315
+ "pluginName": "dhti-cli",
316
+ "pluginType": "core",
317
+ "strict": true,
318
+ "enableJsonFlag": false,
319
+ "isESM": true,
320
+ "relativePath": [
321
+ "dist",
322
+ "commands",
323
+ "docktor.js"
324
+ ]
325
+ },
228
326
  "elixir": {
229
327
  "aliases": [],
230
328
  "args": {
@@ -265,6 +363,12 @@
265
363
  "multiple": false,
266
364
  "type": "option"
267
365
  },
366
+ "dry-run": {
367
+ "description": "Show what changes would be made without actually making them",
368
+ "name": "dry-run",
369
+ "allowNo": false,
370
+ "type": "boolean"
371
+ },
268
372
  "git": {
269
373
  "char": "g",
270
374
  "description": "Github repository to install",
@@ -347,7 +451,14 @@
347
451
  "examples": [
348
452
  "<%= config.bin %> <%= command.id %>"
349
453
  ],
350
- "flags": {},
454
+ "flags": {
455
+ "dry-run": {
456
+ "description": "Show what changes would be made without actually making them",
457
+ "name": "dry-run",
458
+ "allowNo": false,
459
+ "type": "boolean"
460
+ }
461
+ },
351
462
  "hasDynamicHelp": false,
352
463
  "hiddenAliases": [],
353
464
  "id": "mimic",
@@ -386,6 +497,12 @@
386
497
  "<%= config.bin %> <%= command.id %>"
387
498
  ],
388
499
  "flags": {
500
+ "dry-run": {
501
+ "description": "Show what changes would be made without actually making them",
502
+ "name": "dry-run",
503
+ "allowNo": false,
504
+ "type": "boolean"
505
+ },
389
506
  "inputField": {
390
507
  "char": "i",
391
508
  "description": "Input field to use",
@@ -449,5 +566,5 @@
449
566
  ]
450
567
  }
451
568
  },
452
- "version": "0.3.3"
569
+ "version": "0.5.0"
453
570
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "dhti-cli",
3
3
  "description": "DHTI CLI",
4
- "version": "0.3.3",
4
+ "version": "0.5.0",
5
5
  "author": "Bell Eapen",
6
6
  "bin": {
7
7
  "dhti-cli": "bin/run.js"
@@ -13,6 +13,7 @@
13
13
  "@oclif/core": "^4",
14
14
  "@oclif/plugin-help": "^6",
15
15
  "@oclif/plugin-plugins": "^5",
16
+ "chalk": "^4.1.2",
16
17
  "child_process": "^1.0.2",
17
18
  "js-yaml": "^4.1.0",
18
19
  "medpromptjs": ">=0.4.3",