coala-cli 0.2.1__tar.gz → 0.2.4__tar.gz

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.
@@ -0,0 +1,32 @@
1
+ Metadata-Version: 2.4
2
+ Name: coala-cli
3
+ Version: 0.2.4
4
+ Summary: Command-line LLM-agent Adapter
5
+ License: MIT
6
+ Author: Qiang
7
+ Requires-Python: >=3.12,<3.15
8
+ Classifier: License :: OSI Approved :: MIT License
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Programming Language :: Python :: 3.12
11
+ Classifier: Programming Language :: Python :: 3.13
12
+ Classifier: Programming Language :: Python :: 3.14
13
+ Requires-Dist: cwltool (>=3.2.20260413085819,<4.0.0)
14
+ Requires-Dist: fastapi (>=0.115.0)
15
+ Requires-Dist: mcp (>=1.12.0,<2.0.0)
16
+ Requires-Dist: pydantic (>=2.10.0,<3.0.0)
17
+ Requires-Dist: requests (>=2.33.0,<3.0.0)
18
+ Requires-Dist: uvicorn (>=0.32.0,<0.33.0)
19
+ Description-Content-Type: text/markdown
20
+
21
+ # Coala: Command-line LLM-agent Adapter
22
+
23
+
24
+ ## Introduction
25
+
26
+ Coala is a standards-based framework for turning command-line tools into reproducible, agent-accessible toolsets that support natural-language interaction.
27
+
28
+ Coala integrates the [Common Workflow Language (CWL)](https://www.commonwl.org/specification/) with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) to standardize tool execution. This approach allows Large Language Model (LLM) agents to discover and run tools through structured interfaces, while strictly enforcing the containerized environments and deterministic results necessary for reproducible science.
29
+
30
+ Please find Coala documuentations through [Coala website](https://coala.info/), including [overview](https://coala.info/guide/), [installation](https://coala.info/guide/installation.html), [quick start](https://coala.info/guide/quick-start.html), and [use cases](https://coala.info/use-cases/).
31
+
32
+
@@ -0,0 +1,11 @@
1
+ # Coala: Command-line LLM-agent Adapter
2
+
3
+
4
+ ## Introduction
5
+
6
+ Coala is a standards-based framework for turning command-line tools into reproducible, agent-accessible toolsets that support natural-language interaction.
7
+
8
+ Coala integrates the [Common Workflow Language (CWL)](https://www.commonwl.org/specification/) with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) to standardize tool execution. This approach allows Large Language Model (LLM) agents to discover and run tools through structured interfaces, while strictly enforcing the containerized environments and deterministic results necessary for reproducible science.
9
+
10
+ Please find Coala documuentations through [Coala website](https://coala.info/), including [overview](https://coala.info/guide/), [installation](https://coala.info/guide/installation.html), [quick start](https://coala.info/guide/quick-start.html), and [use cases](https://coala.info/use-cases/).
11
+
@@ -5,7 +5,7 @@ warnings.filterwarnings('ignore', message='.*default.*Field.*')
5
5
  warnings.filterwarnings('ignore', message='.*UnsupportedFieldAttributeWarning.*')
6
6
 
7
7
  from fastapi import FastAPI, UploadFile, File
8
- from pydantic import create_model, Field
8
+ from pydantic import create_model, Field, ConfigDict
9
9
  from pydantic.warnings import UnsupportedFieldAttributeWarning
10
10
  import logging
11
11
  import uvicorn
@@ -29,6 +29,13 @@ logger.setLevel(logging.INFO)
29
29
  # Use stderr for logging to avoid interfering with stdio transport
30
30
  logger.addHandler(logging.StreamHandler(sys.stderr))
31
31
 
32
+ # ponytail: pydantic forbids these as field names; alias + by_alias keeps CWL ids intact
33
+ _PYDANTIC_RESERVED_FIELDS = frozenset({'model_config'})
34
+
35
+
36
+ def _pydantic_field_name(cwl_name: str) -> str:
37
+ return f'{cwl_name}_' if cwl_name in _PYDANTIC_RESERVED_FIELDS else cwl_name
38
+
32
39
 
33
40
  class mcp_api():
34
41
  def __init__(self, host='0.0.0.0', port=8000, container_runner=None):
@@ -72,29 +79,69 @@ tool_version: <TOOL_VERSION>
72
79
  # tmp.write(contents)
73
80
  # return {"filename": file.filename, "filepath": tmp.name}
74
81
 
82
+ def _cwl_type_hint(self, type_val):
83
+ """
84
+ Render a human-readable type hint from a CWL type value.
85
+
86
+ Handles optional unions (list containing 'null'), dict-form arrays
87
+ ({'type': 'array', 'items': ...}) and string shorthand arrays
88
+ (e.g. 'float[]'). Returns an empty string when no hint can be
89
+ determined.
90
+ """
91
+ if type_val is None or type_val == '':
92
+ return ""
93
+
94
+ type_list = type_val if isinstance(type_val, list) else [type_val]
95
+ non_null_types = [t for t in type_list if t != 'null']
96
+
97
+ is_array = False
98
+ base_type = None
99
+
100
+ for t in non_null_types:
101
+ if isinstance(t, dict) and t.get('type') == 'array':
102
+ is_array = True
103
+ base_type = t.get('items', 'string')
104
+ break
105
+ t_str = str(t)
106
+ if '[]' in t_str:
107
+ is_array = True
108
+ base_type = t_str.replace('[]', '')
109
+ break
110
+
111
+ if base_type is None and non_null_types:
112
+ base_type = non_null_types[0]
113
+
114
+ base_str = str(base_type) if base_type is not None else ''
115
+
116
+ if 'Directory' in base_str:
117
+ base_hint = "directory path"
118
+ elif 'File' in base_str:
119
+ base_hint = "file path"
120
+ elif 'string' in base_str:
121
+ base_hint = "str"
122
+ elif 'double' in base_str or 'float' in base_str:
123
+ base_hint = "float"
124
+ elif 'int' in base_str:
125
+ base_hint = "int"
126
+ elif 'boolean' in base_str:
127
+ base_hint = "bool"
128
+ else:
129
+ base_hint = ""
130
+
131
+ if not base_hint:
132
+ return ""
133
+
134
+ return f"array of {base_hint}" if is_array else base_hint
135
+
75
136
  def _build_field_description(self, field_name, input_field, model_field):
76
137
  """
77
138
  Build field description with type hints.
78
139
  """
79
140
  doc = input_field.get('doc', '')
80
- type_val = input_field.get('type', '')
81
- type_list = type_val if isinstance(type_val, list) else [type_val]
82
- type_str = ' '.join(str(t) for t in type_list)
83
-
84
- type_hint = ""
85
- if 'File' in type_str:
86
- type_hint = "file path"
87
- elif 'string' in type_str:
88
- type_hint = "str"
89
- elif 'double' in type_str or 'float' in type_str:
90
- type_hint = "float"
91
- elif 'int' in type_str:
92
- type_hint = "int"
93
- elif 'boolean' in type_str:
94
- type_hint = "bool"
95
-
141
+ type_hint = self._cwl_type_hint(input_field.get('type', ''))
142
+
96
143
  annotation = model_field.annotation.__name__ if hasattr(model_field.annotation, '__name__') else str(model_field.annotation)
97
-
144
+
98
145
  if type_hint:
99
146
  return f"{field_name}: {doc}, {annotation}, {type_hint}"
100
147
  else:
@@ -106,22 +153,8 @@ tool_version: <TOOL_VERSION>
106
153
  """
107
154
  field_name = output_field.get('name', '')
108
155
  doc = output_field.get('doc', '')
109
- type_val = output_field.get('type', '')
110
- type_list = type_val if isinstance(type_val, list) else [type_val]
111
- type_str = ' '.join(str(t) for t in type_list)
112
-
113
- type_hint = ""
114
- if 'File' in type_str:
115
- type_hint = "file path"
116
- elif 'string' in type_str:
117
- type_hint = "str"
118
- elif 'double' in type_str or 'float' in type_str:
119
- type_hint = "float"
120
- elif 'int' in type_str:
121
- type_hint = "int"
122
- elif 'boolean' in type_str:
123
- type_hint = "bool"
124
-
156
+ type_hint = self._cwl_type_hint(output_field.get('type', ''))
157
+
125
158
  if type_hint:
126
159
  return f"{field_name}: {doc}, {type_hint}"
127
160
  else:
@@ -132,6 +165,7 @@ tool_version: <TOOL_VERSION>
132
165
  Transform input values based on their expected type.
133
166
 
134
167
  - For File types: If value is just a filename, try to resolve to full path
168
+ - For Directory types: If value is a path, try to resolve to an absolute path
135
169
  - For string types: If value is a full path, extract just the filename
136
170
  - For array types: Transform each element in the array
137
171
 
@@ -173,7 +207,7 @@ tool_version: <TOOL_VERSION>
173
207
  type_str = str(base_type) if not isinstance(base_type, dict) else base_type.get('type', '')
174
208
 
175
209
  # Check if it's a File type
176
- if 'File' in type_str and isinstance(value, str):
210
+ if 'File' in type_str and 'Directory' not in type_str and isinstance(value, str):
177
211
  # If it's already a file:// URI, return as is
178
212
  if value.startswith('file://'):
179
213
  return value
@@ -194,6 +228,23 @@ tool_version: <TOOL_VERSION>
194
228
 
195
229
  # If not found, return as is (let run_tool handle it)
196
230
  return value
231
+
232
+ # Check if it's a Directory type
233
+ if 'Directory' in type_str and isinstance(value, str):
234
+ if value.startswith('file://'):
235
+ return value
236
+
237
+ if os.path.isabs(value) and os.path.isdir(value):
238
+ return value
239
+
240
+ if os.path.isdir(value):
241
+ return os.path.abspath(value)
242
+
243
+ cwd_path = os.path.join(os.getcwd(), value)
244
+ if os.path.isdir(cwd_path):
245
+ return os.path.abspath(cwd_path)
246
+
247
+ return value
197
248
 
198
249
  # Check if it's a string type
199
250
  elif 'string' in type_str and isinstance(value, str):
@@ -203,8 +254,12 @@ tool_version: <TOOL_VERSION>
203
254
  dir_path = os.path.dirname(value)
204
255
  # Only extract filename if the directory exists
205
256
  if dir_path and os.path.isdir(dir_path):
206
- # Extract filename from path
207
- filename = os.path.basename(value)
257
+ # Trailing slashes make os.path.basename return "" — normalize first.
258
+ seps = os.path.sep + (os.path.altsep or "")
259
+ trimmed = value.rstrip(seps)
260
+ filename = os.path.basename(trimmed) if trimmed else value
261
+ if not filename:
262
+ return value
208
263
  logger.info(f"Transformed string input '{field_name}': '{value}' -> '{filename}'")
209
264
  return filename
210
265
  # If directory doesn't exist, keep the full path as is
@@ -286,15 +341,26 @@ tool_version: <TOOL_VERSION>
286
341
  items_type = raw_type.get('items', 'string')
287
342
  base_type_str = str(items_type) if not isinstance(items_type, dict) else items_type.get('type', 'string')
288
343
  elif non_null_types:
289
- # Check for array notation in string (e.g., 'float[]')
290
- # Look through non-null types for array notation
344
+ # Look through non-null types for array notation.
345
+ # An array can appear either as dict form
346
+ # ({'type': 'array', 'items': <items_type>}) or as string shorthand
347
+ # with '[]' suffix (e.g. 'float[]').
291
348
  for t in non_null_types:
349
+ if isinstance(t, dict) and t.get('type') == 'array':
350
+ is_array = True
351
+ items_type = t.get('items', 'string')
352
+ base_type_str = (
353
+ str(items_type)
354
+ if not isinstance(items_type, dict)
355
+ else items_type.get('type', 'string')
356
+ )
357
+ break
292
358
  t_str = str(t)
293
359
  if '[]' in t_str:
294
360
  is_array = True
295
361
  base_type_str = t_str.replace('[]', '')
296
362
  break
297
-
363
+
298
364
  if not is_array and non_null_types:
299
365
  # Not an array, use the first non-null type
300
366
  base_type_str = str(non_null_types[0])
@@ -306,7 +372,7 @@ tool_version: <TOOL_VERSION>
306
372
  field_doc = it.get('doc', '')
307
373
 
308
374
  # Determine base Python type
309
- if 'File' in base_type_str:
375
+ if 'File' in base_type_str or 'Directory' in base_type_str:
310
376
  base_py_type = str
311
377
  elif 'string' in base_type_str:
312
378
  base_py_type = str
@@ -328,17 +394,35 @@ tool_version: <TOOL_VERSION>
328
394
  # Create Field with description
329
395
  # For optional fields, use (Optional[type], None) - Field() can't be used with Union types
330
396
  # For required fields, use Field directly
331
- if is_optional:
397
+ # Reserved pydantic names (e.g. model_config) get a trailing _ and an alias
398
+ cwl_name = it['name']
399
+ field_name = _pydantic_field_name(cwl_name)
400
+ if field_name != cwl_name:
401
+ if is_optional:
402
+ it_map[field_name] = (
403
+ Optional[py_type],
404
+ Field(default=None, alias=cwl_name, description=field_doc),
405
+ )
406
+ else:
407
+ it_map[field_name] = (py_type, Field(alias=cwl_name, description=field_doc))
408
+ elif is_optional:
332
409
  # Use Optional type with None as default
333
410
  # Note: We can't use Field() with Optional/Union types, so description will be set via field_doc in fields_desc
334
- it_map[it['name']] = (Optional[py_type], None)
411
+ it_map[field_name] = (Optional[py_type], None)
335
412
  else:
336
- it_map[it['name']] = (py_type, Field(description=field_doc))
413
+ it_map[field_name] = (py_type, Field(description=field_doc))
337
414
 
338
- Base = create_model(f'Base_{tool_name}', **it_map)
415
+ create_kwargs = {}
416
+ if any(n in _PYDANTIC_RESERVED_FIELDS for n in inputs_by_name):
417
+ create_kwargs['__config__'] = ConfigDict(populate_by_name=True)
418
+ Base = create_model(f'Base_{tool_name}', **create_kwargs, **it_map)
339
419
 
340
420
  fields_desc = "\n\n".join(
341
- self._build_field_description(k, inputs_by_name[k], v)
421
+ self._build_field_description(
422
+ (v.alias or k),
423
+ inputs_by_name[v.alias or k],
424
+ v,
425
+ )
342
426
  for k, v in Base.model_fields.items()
343
427
  )
344
428
 
@@ -384,7 +468,8 @@ tool_version: <TOOL_VERSION>
384
468
  {fields_desc}
385
469
  """
386
470
  logger.info(data)
387
- params = data[0].model_dump()
471
+ # by_alias=True restores CWL input ids when pydantic reserved names were aliased
472
+ params = data[0].model_dump(by_alias=True)
388
473
 
389
474
  # Transform input values based on their types
390
475
  # Create a mapping from field name to input type
@@ -1,5 +1,5 @@
1
1
  from fastapi import FastAPI, UploadFile, Body
2
- from pydantic import create_model
2
+ from pydantic import create_model, Field, ConfigDict
3
3
  import logging
4
4
  import uvicorn
5
5
  from tempfile import NamedTemporaryFile, mkdtemp
@@ -15,6 +15,13 @@ logger = logging.getLogger(__name__)
15
15
  logger.setLevel(logging.INFO)
16
16
  logger.addHandler(logging.StreamHandler())
17
17
 
18
+ # ponytail: pydantic forbids these as field names; alias + by_alias keeps CWL ids intact
19
+ _PYDANTIC_RESERVED_FIELDS = frozenset({'model_config'})
20
+
21
+
22
+ def _pydantic_field_name(cwl_name: str) -> str:
23
+ return f'{cwl_name}_' if cwl_name in _PYDANTIC_RESERVED_FIELDS else cwl_name
24
+
18
25
 
19
26
  class tool_api():
20
27
  def __init__(self, cwl_file, tool_name='tool', host='0.0.0.0', port=8000, read_outs=False):
@@ -53,25 +60,34 @@ class tool_api():
53
60
  # it['type'] can be a list like ['null', 'org.w3id.cwl.cwl.File']
54
61
  type_list = it['type'] if isinstance(it['type'], list) else [it['type']]
55
62
  type_str = ' '.join(str(t) for t in type_list) # Join for checking substrings
56
-
63
+ cwl_name = it['name']
64
+ field_name = _pydantic_field_name(cwl_name)
65
+
57
66
  if 'File' in type_str:
58
- it_map[it['name']] = (str, None)
67
+ py_type, default = str, None
59
68
  elif 'string' in type_str:
60
- it_map[it['name']] = (str, None)
69
+ py_type, default = str, None
61
70
  elif 'double' in type_str:
62
- it_map[it['name']] = (float, None)
71
+ py_type, default = float, None
63
72
  elif 'int' in type_str:
64
- it_map[it['name']] = (int, None)
73
+ py_type, default = int, None
65
74
  elif 'boolean' in type_str:
66
- it_map[it['name']] = (bool, None)
75
+ py_type, default = bool, None
67
76
  else:
68
- it_map[it['name']] = (str, None)
77
+ py_type, default = str, None
69
78
 
70
79
  if 'null' in type_list:
71
- type, v = it_map[it['name']]
72
- it_map[it['name']] = (Optional[type], v)
73
-
74
- self.Base = create_model('Base', **it_map)
80
+ py_type = Optional[py_type]
81
+
82
+ if field_name != cwl_name:
83
+ it_map[field_name] = (py_type, Field(default=default, alias=cwl_name))
84
+ else:
85
+ it_map[field_name] = (py_type, default)
86
+
87
+ create_kwargs = {}
88
+ if any(it['name'] in _PYDANTIC_RESERVED_FIELDS for it in self.inputs):
89
+ create_kwargs['__config__'] = ConfigDict(populate_by_name=True)
90
+ self.Base = create_model('Base', **create_kwargs, **it_map)
75
91
 
76
92
  # define tool
77
93
  # fastapi
@@ -87,7 +103,7 @@ class tool_api():
87
103
  @self.app.post(f"/{self.tool_name}/")
88
104
  def tool(data: List[self.Base] = Body(...)):
89
105
  logger.info(data)
90
- params = data[0].model_dump()
106
+ params = data[0].model_dump(by_alias=True)
91
107
  outs = run_tool(self.tool, params, self.outputs, self.read_outs)
92
108
  logger.info(outs)
93
109
  return outs
@@ -0,0 +1,222 @@
1
+ # coala/tool_logic.py
2
+ import os
3
+ import os.path
4
+ import gzip
5
+ from pathlib import Path
6
+ from urllib.parse import urlparse, unquote
7
+ from urllib.request import url2pathname
8
+ from cwltool.context import RuntimeContext
9
+
10
+
11
+ def _canonical_file_uri(uri: str) -> str:
12
+ """Decode file:// to a local path, resolve it, return canonical file:// URI."""
13
+ parsed = urlparse(uri)
14
+ if parsed.scheme != "file":
15
+ raise ValueError("expected file URI")
16
+ path_part = unquote(parsed.path or "")
17
+ if os.name == "nt":
18
+ local = url2pathname(path_part)
19
+ else:
20
+ local = path_part
21
+ return Path(local).resolve().as_uri()
22
+
23
+
24
+ def _local_path_to_file_uri(path: str) -> str:
25
+ """Build a file:// URI from a bare or relative local path (resolved)."""
26
+ p = Path(path).expanduser()
27
+ if not p.is_absolute():
28
+ p = Path(os.getcwd()) / p
29
+ return p.resolve().as_uri()
30
+
31
+
32
+ def _remote_uri_string(s: str) -> bool:
33
+ """True if s is a non-file URI (http(s), ftp, s3, …) — CWL string, not File."""
34
+ if s.startswith(("http://", "https://", "ftp://")):
35
+ return True
36
+ return "://" in s and not s.startswith("file://")
37
+
38
+
39
+ def configure_container_runner(runtime_context: RuntimeContext, container_runner: str) -> None:
40
+ """
41
+ Configure the runtime context with the specified container runner.
42
+
43
+ Parameters:
44
+ runtime_context: The RuntimeContext to configure
45
+ container_runner: 'docker', 'podman', 'singularity', 'udocker', or 'xcrunner'
46
+ """
47
+ runtime_context.default_container = container_runner
48
+ runtime_context.singularity = (container_runner == 'singularity')
49
+ runtime_context.podman = (container_runner == 'podman')
50
+ if container_runner == 'xcrunner':
51
+ import shutil
52
+ # xcrunner = xcodon-runtime; its command is `xcrunner` (the `xcodon` command is the agent).
53
+ import os, sys
54
+ beside_python = os.path.join(os.path.dirname(sys.executable), 'xcrunner')
55
+ runtime_context.user_space_docker_cmd = (
56
+ (beside_python if os.access(beside_python, os.X_OK) else None)
57
+ or shutil.which('xcrunner') or 'xcrunner'
58
+ )
59
+
60
+ def _read_file_content(filepath):
61
+ """Read file content, handling gzipped files."""
62
+ try:
63
+ if filepath.endswith('.gz'):
64
+ with gzip.open(filepath, 'rt', encoding='utf-8') as f:
65
+ return f.read().replace('\n', '')
66
+ else:
67
+ with open(filepath, 'r', encoding='utf-8') as f:
68
+ return f.read().replace('\n', '')
69
+ except (UnicodeDecodeError, OSError):
70
+ # If reading fails (binary file, etc.), return the filepath instead
71
+ return filepath
72
+
73
+ def run_tool(tool, params, outputs, read_outs=False, container_runner=None):
74
+ """
75
+ Execute a CWL tool with the given parameters.
76
+
77
+ Parameters:
78
+ tool: The CWL tool object (created via factory.Factory().make())
79
+ params: Dictionary of input parameters
80
+ outputs: List of output field definitions
81
+ read_outs: Whether to read output file contents (default: False)
82
+ container_runner: Container runtime to use (default: None, uses tool's default)
83
+ Valid values: 'docker', 'podman', 'singularity', 'udocker', etc.
84
+
85
+ Returns:
86
+ Dictionary mapping output field names to their values
87
+ """
88
+ # Prepare params for CWL tool
89
+ inputs = tool.t.inputs_record_schema['fields']
90
+ in_dict = {}
91
+ for i in inputs:
92
+ in_dict[i['name']] = i['type']
93
+
94
+ def path_to_file_obj(path_or_file):
95
+ """Local paths and file:// become CWL File; remote URIs stay plain strings."""
96
+ if path_or_file is None:
97
+ return None
98
+ if isinstance(path_or_file, dict):
99
+ loc = path_or_file.get("location")
100
+ if isinstance(loc, str):
101
+ if _remote_uri_string(loc):
102
+ return loc
103
+ if loc.startswith("file://"):
104
+ return {"class": "File", "location": _canonical_file_uri(loc)}
105
+ return {"class": "File", "location": _local_path_to_file_uri(loc)}
106
+ return path_or_file
107
+ if isinstance(path_or_file, str):
108
+ if _remote_uri_string(path_or_file):
109
+ return path_or_file
110
+ if path_or_file.startswith("file://"):
111
+ return {"class": "File", "location": _canonical_file_uri(path_or_file)}
112
+ return {"class": "File", "location": _local_path_to_file_uri(path_or_file)}
113
+ return None
114
+
115
+ def path_to_directory_obj(path_or_dir):
116
+ """Local paths and file:// become CWL Directory; remote URIs stay plain strings."""
117
+ if path_or_dir is None:
118
+ return None
119
+ if isinstance(path_or_dir, dict):
120
+ loc = path_or_dir.get("location")
121
+ if isinstance(loc, str):
122
+ if _remote_uri_string(loc):
123
+ return loc
124
+ if loc.startswith("file://"):
125
+ return {"class": "Directory", "location": _canonical_file_uri(loc)}
126
+ return {"class": "Directory", "location": _local_path_to_file_uri(loc)}
127
+ return path_or_dir
128
+ if isinstance(path_or_dir, str):
129
+ if _remote_uri_string(path_or_dir):
130
+ return path_or_dir
131
+ if path_or_dir.startswith("file://"):
132
+ return {"class": "Directory", "location": _canonical_file_uri(path_or_dir)}
133
+ return {"class": "Directory", "location": _local_path_to_file_uri(path_or_dir)}
134
+ return None
135
+
136
+ def _is_path_array_type(typ, path_kind):
137
+ """True if typ is array of File or Directory (dict-like or in a union list)."""
138
+ if hasattr(typ, 'get') and typ.get('type') == 'array':
139
+ items = typ.get('items', '')
140
+ return path_kind in str(items)
141
+ if isinstance(typ, list):
142
+ for t in typ:
143
+ if t == 'null':
144
+ continue
145
+ if hasattr(t, 'get') and t.get('type') == 'array':
146
+ if path_kind in str(t.get('items', '')):
147
+ return True
148
+ return False
149
+ return False
150
+
151
+ def _is_single_path_type(typ, path_kind):
152
+ """True if typ is single File or Directory (not array)."""
153
+ if hasattr(typ, 'get') and typ.get('type') == 'array':
154
+ return False
155
+ if isinstance(typ, list):
156
+ for t in typ:
157
+ if t != 'null' and hasattr(t, 'get') and t.get('type') == 'array':
158
+ return False
159
+ return path_kind in ' '.join(str(t) for t in typ)
160
+ return path_kind in str(typ) and '[]' not in str(typ)
161
+
162
+ path_converters = {
163
+ 'File': path_to_file_obj,
164
+ 'Directory': path_to_directory_obj,
165
+ }
166
+
167
+ for k, v in params.items():
168
+ if k not in in_dict or v is None:
169
+ continue
170
+ type_val = in_dict[k]
171
+ type_str = ' '.join(str(t) for t in type_val) if isinstance(type_val, list) else str(type_val)
172
+ for path_kind, converter in path_converters.items():
173
+ if path_kind not in type_str:
174
+ continue
175
+ is_path_array = _is_path_array_type(type_val, path_kind)
176
+ if is_path_array and isinstance(v, list):
177
+ converted = [converter(item) for item in v]
178
+ if all(x is not None for x in converted):
179
+ params[k] = converted
180
+ elif _is_single_path_type(type_val, path_kind):
181
+ to_convert = v[0] if isinstance(v, list) and len(v) > 0 else v
182
+ path_obj = converter(to_convert)
183
+ if path_obj is not None:
184
+ params[k] = path_obj
185
+ break
186
+
187
+ # Modify the tool's runtime context if container runner is specified
188
+ if container_runner:
189
+ # Try to get the original runtime context from the tool
190
+ original_runtime_context = None
191
+ if hasattr(tool, 'runtime_context'):
192
+ original_runtime_context = tool.runtime_context
193
+ elif hasattr(tool, 't') and hasattr(tool.t, 'runtime_context'):
194
+ original_runtime_context = tool.t.runtime_context
195
+
196
+ # If we found the runtime context, modify it in place
197
+ if original_runtime_context:
198
+ configure_container_runner(original_runtime_context, container_runner)
199
+
200
+ # Execute tool (no need to pass runtime_context if we modified it in place)
201
+ res = tool(**params)
202
+ outs = {}
203
+ for ot in outputs:
204
+ out_content = res[ot['name']]
205
+ # Handle both list and string types (e.g., ['null', 'File'] or 'File?')
206
+ # Convert each item to str to handle CommentedMap from ruamel.yaml (enum types)
207
+ type_val = ot['type']
208
+ type_str = ' '.join(str(t) for t in type_val) if isinstance(type_val, list) else str(type_val)
209
+ if read_outs and 'File' in type_str:
210
+ # Handle both single File and File[] (array) outputs
211
+ file_result = res[ot['name']]
212
+ if isinstance(file_result, list):
213
+ # File[] - read first file
214
+ if len(file_result) > 0:
215
+ out_file = file_result[0]['location'].replace('file://', '')
216
+ out_content = _read_file_content(out_file)
217
+ else:
218
+ # Single File
219
+ out_file = file_result['location'].replace('file://', '')
220
+ out_content = _read_file_content(out_file)
221
+ outs[ot['name']] = out_content
222
+ return outs
@@ -1,20 +1,20 @@
1
1
  [tool.poetry]
2
2
  name = "coala-cli"
3
- version = "0.2.1"
4
- description = "Convert any CMD tool into a LLM agent"
3
+ version = "0.2.4"
4
+ description = "Command-line LLM-agent Adapter"
5
5
  authors = ["Qiang"]
6
6
  license = "MIT"
7
7
  readme = "README.md"
8
8
  packages = [{ include = "coala" }]
9
9
 
10
10
  [tool.poetry.dependencies]
11
- python = ">=3.12,<3.14"
12
- fastapi = ">=0.114.0"
13
- requests = "^2.32.3"
14
- pydantic = "^2.9.2"
15
- uvicorn = "^0.30.6"
16
- cwltool = "^3.1.20240909164951"
17
- mcp = "^1.9.0"
11
+ python = ">=3.12,<3.15"
12
+ fastapi = ">=0.115.0"
13
+ requests = "^2.33.0"
14
+ pydantic = "^2.10.0"
15
+ uvicorn = "^0.32.0"
16
+ cwltool = "^3.2.20260413085819"
17
+ mcp = "^1.12.0"
18
18
 
19
19
 
20
20
  [build-system]
coala_cli-0.2.1/PKG-INFO DELETED
@@ -1,158 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: coala-cli
3
- Version: 0.2.1
4
- Summary: Convert any CMD tool into a LLM agent
5
- License: MIT
6
- Author: Qiang
7
- Requires-Python: >=3.12,<3.14
8
- Classifier: License :: OSI Approved :: MIT License
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.12
11
- Classifier: Programming Language :: Python :: 3.13
12
- Requires-Dist: cwltool (>=3.1.20240909164951,<4.0.0)
13
- Requires-Dist: fastapi (>=0.114.0)
14
- Requires-Dist: mcp (>=1.9.0,<2.0.0)
15
- Requires-Dist: pydantic (>=2.9.2,<3.0.0)
16
- Requires-Dist: requests (>=2.32.3,<3.0.0)
17
- Requires-Dist: uvicorn (>=0.30.6,<0.31.0)
18
- Description-Content-Type: text/markdown
19
-
20
- # coala-cli
21
- ======================
22
-
23
- ## Overview
24
-
25
- Coala, implemented as a Python package, is a standards-based framework for turning command-line tools into reproducible, agent-accessible toolsets that support natural-language interaction.
26
-
27
- ## How the Framework Works
28
-
29
- Coala integrates the [Common Workflow Language (CWL)](https://www.commonwl.org/specification/) with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) to standardize tool execution. This approach allows Large Language Model (LLM) agents to discover and run tools through structured interfaces, while strictly enforcing the containerized environments and deterministic results necessary for reproducible science.
30
-
31
- ### Core Components
32
- - **Client Layer:** Any MCP-compliant client application (e.g., Claude Desktop, Cursor, or custom interfaces) that utilizes LLMs (such as Gemini, GPT-5, or Claude) to enable natural language interaction.
33
- - **Bridge Layer:** A local, generic MCP server that acts as a schema translator. Unlike standard MCP servers that require custom Python wrappers for each tool, the bridge layer automatically parses CWL definitions and exposes the CWL-described command-line tools as executable MCP utilities.
34
- - **Execution Layer:** A standard CWL runner that executes the underlying binaries within containerized environments (Docker). This ensures that analyses are reproducible and isolated from the host system's dependencies.
35
-
36
- ### Quick Start
37
-
38
- 1. **Initialize:** Create a local MCP server instance using `mcp_api()`.
39
- 2. **Register:** Load your domain-specific tools described in CWL via `add_tool()` (supports local files or repositories).
40
- 3. **Serve:** Start the MCP server using `mcp.serve()`.
41
-
42
- ### The Workflow
43
-
44
- - **Interact:** The user sends a natural language query to the MCP Client (e.g., Claude Desktop).
45
- - **Discover & Select:** The Client retrieves the tool list from the MCP server. The LLM selects the appropriate tool and sends a structured request for the analysis.
46
- - **Execute:** Coala translates this selection into a CWL job and executes it within a container (Docker), ensuring reproducibility.
47
- - **Respond:** The execution logs and results are returned to the LLM, which interprets them and presents the final answer to the user.
48
-
49
- ## Get Started
50
-
51
- ### Requirements
52
-
53
- * Python 3.12 or later
54
- * FastAPI
55
- * Requests
56
- * Pydantic
57
- * Uvicorn
58
- * cwltool
59
- * mcp (Model Context Protocol SDK)
60
-
61
- ### Installation
62
-
63
- To install coala-cli, run the following command:
64
- ```bash
65
- pip install coala-cli
66
- ```
67
-
68
- ### Use Cases
69
-
70
-
71
- <!-- This text is a hidden note and will not be displayed in the rendered README.
72
-
73
- ### MCP server
74
-
75
- The framework allows you to set up an MCP server with predefined tools for specific domains. For example, to create a bioinformatics-focused MCP server, you can use the following setup (as shown in [`examples/bioinfo_question.py`](examples/bioinfo_question.py)):
76
-
77
- ```python
78
- from cmdagent.mcp_api import mcp_api
79
-
80
- mcp = mcp_api(host='0.0.0.0', port=8000)
81
- mcp.add_tool('examples/ncbi_datasets_gene.cwl', 'ncbi_datasets_gene')
82
- mcp.add_tool('examples/bcftools_view.cwl', 'bcftools_view', read_outs=False)
83
- mcp.serve()
84
- ```
85
-
86
- This creates an MCP server that exposes two bioinformatics tools:
87
- - `ncbi_datasets_gene`: Retrieves gene metadata from NCBI datasets
88
- - `bcftools_view`: Subsets and filters VCF/BCF files
89
-
90
- Once the server is running, you can configure your MCP client (e.g., in Cursor) to connect to it:
91
-
92
- ```json
93
- {
94
- "mcpServers": {
95
- "cmdagent": {
96
- "url": "http://localhost:8000/mcp",
97
- "transport": "streamable-http"
98
- }
99
- }
100
- }
101
- ```
102
-
103
- With this setup, you can ask the LLM natural language questions like:
104
- - "Give me a summary about gene BRCA1"
105
- - "Subset variants in the gene BRCA1 from the https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz"
106
-
107
- The LLM will automatically discover the available tools, understand their parameters, invoke the appropriate tool with the correct arguments, and present the results in a user-friendly format.
108
-
109
- * Start MCP server
110
- ```
111
- python examples/bioinfo_question.py
112
- ```
113
-
114
- * Call by MCP client from Cursor
115
- [![Demo md5](tests/cmdagent.gif)](https://www.youtube.com/watch?v=QqevFmQbTDU)
116
-
117
-
118
- ### Function call
119
- * Creating an API
120
-
121
- To create an API, import the `tool_api` function from `cmdagent.remote_api` and pass in the path to a CWL file and the name of the tool:
122
- ```python
123
- from cmdagent.remote_api import tool_api
124
-
125
- api = tool_api(cwl_file='tests/dockstore-tool-md5sum.cwl', tool_name='md5sum')
126
- api.serve()
127
- ```
128
- The `api.serve()` method will start a RESTful API as a service, allowing you to run the tool remotely from the cloud or locally.
129
-
130
- * Creating a Tool Agent
131
-
132
- To create a tool agent, import the `cmdagent` function from `cmdagent.agent` and pass in the API instance:
133
- ```python
134
- from cmdagent.agent import tool_agent
135
-
136
- ta = tool_agent(api)
137
- md5 = ta.create_tool()
138
- md5(input_file="tests/dockstore-tool-md5sum.cwl")
139
- ```
140
- Function `md5` is created automatically based on the `api`.
141
-
142
- * Function call with Gemini
143
-
144
- To integrate the tool agent with Gemini, import the `GenerativeModel` class from `google.generativeai` and create a new instance:
145
- ```python
146
- import google.generativeai as genai
147
-
148
- genai.configure(api_key="******")
149
- model = genai.GenerativeModel(model_name='gemini-1.5-flash', tools=[md5])
150
-
151
- chat = model.start_chat(enable_automatic_function_calling=True)
152
- response = chat.send_message("what is md5 of tests/dockstore-tool-md5sum.cwl?")
153
- response.text
154
- ```
155
- ```
156
- 'The md5sum of tests/dockstore-tool-md5sum.cwl is ad59d9e9ed6344f5c20ee7e0143c6c12. \n'
157
- ```
158
- -->
coala_cli-0.2.1/README.md DELETED
@@ -1,139 +0,0 @@
1
- # coala-cli
2
- ======================
3
-
4
- ## Overview
5
-
6
- Coala, implemented as a Python package, is a standards-based framework for turning command-line tools into reproducible, agent-accessible toolsets that support natural-language interaction.
7
-
8
- ## How the Framework Works
9
-
10
- Coala integrates the [Common Workflow Language (CWL)](https://www.commonwl.org/specification/) with the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/docs/getting-started/intro) to standardize tool execution. This approach allows Large Language Model (LLM) agents to discover and run tools through structured interfaces, while strictly enforcing the containerized environments and deterministic results necessary for reproducible science.
11
-
12
- ### Core Components
13
- - **Client Layer:** Any MCP-compliant client application (e.g., Claude Desktop, Cursor, or custom interfaces) that utilizes LLMs (such as Gemini, GPT-5, or Claude) to enable natural language interaction.
14
- - **Bridge Layer:** A local, generic MCP server that acts as a schema translator. Unlike standard MCP servers that require custom Python wrappers for each tool, the bridge layer automatically parses CWL definitions and exposes the CWL-described command-line tools as executable MCP utilities.
15
- - **Execution Layer:** A standard CWL runner that executes the underlying binaries within containerized environments (Docker). This ensures that analyses are reproducible and isolated from the host system's dependencies.
16
-
17
- ### Quick Start
18
-
19
- 1. **Initialize:** Create a local MCP server instance using `mcp_api()`.
20
- 2. **Register:** Load your domain-specific tools described in CWL via `add_tool()` (supports local files or repositories).
21
- 3. **Serve:** Start the MCP server using `mcp.serve()`.
22
-
23
- ### The Workflow
24
-
25
- - **Interact:** The user sends a natural language query to the MCP Client (e.g., Claude Desktop).
26
- - **Discover & Select:** The Client retrieves the tool list from the MCP server. The LLM selects the appropriate tool and sends a structured request for the analysis.
27
- - **Execute:** Coala translates this selection into a CWL job and executes it within a container (Docker), ensuring reproducibility.
28
- - **Respond:** The execution logs and results are returned to the LLM, which interprets them and presents the final answer to the user.
29
-
30
- ## Get Started
31
-
32
- ### Requirements
33
-
34
- * Python 3.12 or later
35
- * FastAPI
36
- * Requests
37
- * Pydantic
38
- * Uvicorn
39
- * cwltool
40
- * mcp (Model Context Protocol SDK)
41
-
42
- ### Installation
43
-
44
- To install coala-cli, run the following command:
45
- ```bash
46
- pip install coala-cli
47
- ```
48
-
49
- ### Use Cases
50
-
51
-
52
- <!-- This text is a hidden note and will not be displayed in the rendered README.
53
-
54
- ### MCP server
55
-
56
- The framework allows you to set up an MCP server with predefined tools for specific domains. For example, to create a bioinformatics-focused MCP server, you can use the following setup (as shown in [`examples/bioinfo_question.py`](examples/bioinfo_question.py)):
57
-
58
- ```python
59
- from cmdagent.mcp_api import mcp_api
60
-
61
- mcp = mcp_api(host='0.0.0.0', port=8000)
62
- mcp.add_tool('examples/ncbi_datasets_gene.cwl', 'ncbi_datasets_gene')
63
- mcp.add_tool('examples/bcftools_view.cwl', 'bcftools_view', read_outs=False)
64
- mcp.serve()
65
- ```
66
-
67
- This creates an MCP server that exposes two bioinformatics tools:
68
- - `ncbi_datasets_gene`: Retrieves gene metadata from NCBI datasets
69
- - `bcftools_view`: Subsets and filters VCF/BCF files
70
-
71
- Once the server is running, you can configure your MCP client (e.g., in Cursor) to connect to it:
72
-
73
- ```json
74
- {
75
- "mcpServers": {
76
- "cmdagent": {
77
- "url": "http://localhost:8000/mcp",
78
- "transport": "streamable-http"
79
- }
80
- }
81
- }
82
- ```
83
-
84
- With this setup, you can ask the LLM natural language questions like:
85
- - "Give me a summary about gene BRCA1"
86
- - "Subset variants in the gene BRCA1 from the https://ftp.ncbi.nlm.nih.gov/pub/clinvar/vcf_GRCh38/clinvar.vcf.gz"
87
-
88
- The LLM will automatically discover the available tools, understand their parameters, invoke the appropriate tool with the correct arguments, and present the results in a user-friendly format.
89
-
90
- * Start MCP server
91
- ```
92
- python examples/bioinfo_question.py
93
- ```
94
-
95
- * Call by MCP client from Cursor
96
- [![Demo md5](tests/cmdagent.gif)](https://www.youtube.com/watch?v=QqevFmQbTDU)
97
-
98
-
99
- ### Function call
100
- * Creating an API
101
-
102
- To create an API, import the `tool_api` function from `cmdagent.remote_api` and pass in the path to a CWL file and the name of the tool:
103
- ```python
104
- from cmdagent.remote_api import tool_api
105
-
106
- api = tool_api(cwl_file='tests/dockstore-tool-md5sum.cwl', tool_name='md5sum')
107
- api.serve()
108
- ```
109
- The `api.serve()` method will start a RESTful API as a service, allowing you to run the tool remotely from the cloud or locally.
110
-
111
- * Creating a Tool Agent
112
-
113
- To create a tool agent, import the `cmdagent` function from `cmdagent.agent` and pass in the API instance:
114
- ```python
115
- from cmdagent.agent import tool_agent
116
-
117
- ta = tool_agent(api)
118
- md5 = ta.create_tool()
119
- md5(input_file="tests/dockstore-tool-md5sum.cwl")
120
- ```
121
- Function `md5` is created automatically based on the `api`.
122
-
123
- * Function call with Gemini
124
-
125
- To integrate the tool agent with Gemini, import the `GenerativeModel` class from `google.generativeai` and create a new instance:
126
- ```python
127
- import google.generativeai as genai
128
-
129
- genai.configure(api_key="******")
130
- model = genai.GenerativeModel(model_name='gemini-1.5-flash', tools=[md5])
131
-
132
- chat = model.start_chat(enable_automatic_function_calling=True)
133
- response = chat.send_message("what is md5 of tests/dockstore-tool-md5sum.cwl?")
134
- response.text
135
- ```
136
- ```
137
- 'The md5sum of tests/dockstore-tool-md5sum.cwl is ad59d9e9ed6344f5c20ee7e0143c6c12. \n'
138
- ```
139
- -->
@@ -1,109 +0,0 @@
1
- # coala/tool_logic.py
2
- import os.path
3
- import gzip
4
- from cwltool.context import RuntimeContext
5
-
6
- def configure_container_runner(runtime_context: RuntimeContext, container_runner: str) -> None:
7
- """
8
- Configure the runtime context with the specified container runner.
9
-
10
- Parameters:
11
- runtime_context: The RuntimeContext to configure
12
- container_runner: Container runtime to use ('docker', 'podman', 'singularity', 'udocker', etc.)
13
- """
14
- runtime_context.default_container = container_runner
15
- # Set boolean flags for specific container runners
16
- runtime_context.singularity = (container_runner == 'singularity')
17
- runtime_context.podman = (container_runner == 'podman')
18
-
19
- def _read_file_content(filepath):
20
- """Read file content, handling gzipped files."""
21
- try:
22
- if filepath.endswith('.gz'):
23
- with gzip.open(filepath, 'rt', encoding='utf-8') as f:
24
- return f.read().replace('\n', '')
25
- else:
26
- with open(filepath, 'r', encoding='utf-8') as f:
27
- return f.read().replace('\n', '')
28
- except (UnicodeDecodeError, OSError):
29
- # If reading fails (binary file, etc.), return the filepath instead
30
- return filepath
31
-
32
- def run_tool(tool, params, outputs, read_outs=False, container_runner=None):
33
- """
34
- Execute a CWL tool with the given parameters.
35
-
36
- Parameters:
37
- tool: The CWL tool object (created via factory.Factory().make())
38
- params: Dictionary of input parameters
39
- outputs: List of output field definitions
40
- read_outs: Whether to read output file contents (default: False)
41
- container_runner: Container runtime to use (default: None, uses tool's default)
42
- Valid values: 'docker', 'podman', 'singularity', 'udocker', etc.
43
-
44
- Returns:
45
- Dictionary mapping output field names to their values
46
- """
47
- # Prepare params for CWL tool
48
- inputs = tool.t.inputs_record_schema['fields']
49
- in_dict = {}
50
- for i in inputs:
51
- in_dict[i['name']] = i['type']
52
-
53
- for k, v in params.items():
54
- if k in in_dict:
55
- type_val = in_dict[k]
56
- # Handle both list and string types (e.g., ['null', 'File'] or 'File?')
57
- # Convert each item to str to handle CommentedMap from ruamel.yaml (enum types)
58
- type_str = ' '.join(str(t) for t in type_val) if isinstance(type_val, list) else str(type_val)
59
- if 'File' in type_str and v is not None:
60
- if type(v) is dict and 'location' in v:
61
- location = v['location']
62
- elif isinstance(v, str) and v.startswith('file://'):
63
- location = v
64
- elif isinstance(v, str) and os.path.isfile(v):
65
- location = f"file://{v}"
66
- else:
67
- continue # Do nothing if v is not a file
68
-
69
- params[k] = {
70
- "class": "File",
71
- "location": location
72
- }
73
-
74
- # Modify the tool's runtime context if container runner is specified
75
- if container_runner:
76
- # Try to get the original runtime context from the tool
77
- original_runtime_context = None
78
- if hasattr(tool, 'runtime_context'):
79
- original_runtime_context = tool.runtime_context
80
- elif hasattr(tool, 't') and hasattr(tool.t, 'runtime_context'):
81
- original_runtime_context = tool.t.runtime_context
82
-
83
- # If we found the runtime context, modify it in place
84
- if original_runtime_context:
85
- configure_container_runner(original_runtime_context, container_runner)
86
-
87
- # Execute tool (no need to pass runtime_context if we modified it in place)
88
- res = tool(**params)
89
- outs = {}
90
- for ot in outputs:
91
- out_content = res[ot['name']]
92
- # Handle both list and string types (e.g., ['null', 'File'] or 'File?')
93
- # Convert each item to str to handle CommentedMap from ruamel.yaml (enum types)
94
- type_val = ot['type']
95
- type_str = ' '.join(str(t) for t in type_val) if isinstance(type_val, list) else str(type_val)
96
- if read_outs and 'File' in type_str:
97
- # Handle both single File and File[] (array) outputs
98
- file_result = res[ot['name']]
99
- if isinstance(file_result, list):
100
- # File[] - read first file
101
- if len(file_result) > 0:
102
- out_file = file_result[0]['location'].replace('file://', '')
103
- out_content = _read_file_content(out_file)
104
- else:
105
- # Single File
106
- out_file = file_result['location'].replace('file://', '')
107
- out_content = _read_file_content(out_file)
108
- outs[ot['name']] = out_content
109
- return outs
File without changes
File without changes