cobalt-cli-linux 0.1.0__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,113 @@
1
+ Metadata-Version: 2.4
2
+ Name: cobalt-cli-linux
3
+ Version: 0.1.0
4
+ Summary: Agentic Groq-powered CLI assistant for Debian Linux
5
+ Author: Cobalt
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/owall4229/cobalt-cli-linux
8
+ Project-URL: Repository, https://github.com/owall4229/cobalt-cli-linux
9
+ Project-URL: Issues, https://github.com/owall4229/cobalt-cli-linux/issues
10
+ Keywords: cli,groq,agent,linux,assistant
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.10
13
+ Classifier: Programming Language :: Python :: 3.11
14
+ Classifier: Programming Language :: Python :: 3.12
15
+ Classifier: Environment :: Console
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Requires-Python: >=3.10
18
+ Description-Content-Type: text/markdown
19
+ Requires-Dist: httpx>=0.27.0
20
+ Provides-Extra: dev
21
+ Requires-Dist: pytest>=8.0.0; extra == "dev"
22
+ Requires-Dist: ruff>=0.5.0; extra == "dev"
23
+ Requires-Dist: build>=1.2.0; extra == "dev"
24
+ Requires-Dist: twine>=5.0.0; extra == "dev"
25
+
26
+ # cobalt-cli-linux
27
+
28
+ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It can execute shell commands, read and write local files, maintain a local conversation history, and interact with Groq in a production-friendly Python package layout.
29
+
30
+ ## Features
31
+
32
+ - Groq chat completions via HTTP client
33
+ - Safe local shell execution with bash
34
+ - Persistent JSON conversation history in the user's home directory
35
+ - File read/write operations for scriptable agent workflows
36
+ - PyPI-ready package metadata and console script entrypoint
37
+
38
+ ## Installation
39
+
40
+ ```bash
41
+ python -m pip install cobalt-cli-linux
42
+ ```
43
+
44
+ ## Built-in key and .env override
45
+
46
+ The CLI ships with a built-in Groq API key and uses it automatically, so end users do not have to add their own key when they install the package.
47
+
48
+ If you want to override it for development or a local custom setup, create a `.env` file in the project root or in your home directory with:
49
+
50
+ ```bash
51
+ GROQ_API_KEY="your_groq_key_here"
52
+ GROQ_MODEL="llama-3.3-70b-versatile"
53
+ GROQ_BASE_URL="https://api.groq.com/openai/v1"
54
+ ```
55
+
56
+ The CLI automatically reads `.env` entries when present, but it will keep using the built-in key unless you explicitly set `GROQ_API_KEY`. The default built-in key shipped with the package is:
57
+
58
+ ```bash
59
+ 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
60
+ ```
61
+
62
+ ## Run command
63
+
64
+ ### Standard command mode
65
+
66
+ ```bash
67
+ cobalt "List the top 10 files in the current directory"
68
+ ```
69
+
70
+ ### Full-screen interactive mode
71
+
72
+ ```bash
73
+ cobalt --interactive
74
+ ```
75
+
76
+ This launches a fullscreen TUI that feels similar to a professional agent console, with the message stream and a compact prompt area instead of a raw shell prompt.
77
+
78
+ You can also pass arguments directly:
79
+
80
+ ```bash
81
+ cobalt --api-key "$GROQ_API_KEY" "Show the current user and OS info"
82
+ ```
83
+
84
+ ## Project layout
85
+
86
+ - src/cobalt_cli_linux/agent.py - orchestration logic for tool use and model responses
87
+ - src/cobalt_cli_linux/cli.py - command-line entrypoint
88
+ - src/cobalt_cli_linux/groq_client.py - Groq HTTP client
89
+ - src/cobalt_cli_linux/executor.py - shell command execution
90
+ - src/cobalt_cli_linux/history.py - local JSON conversation history
91
+ - src/cobalt_cli_linux/config.py - environment-based settings
92
+
93
+ ## Publish to PyPI
94
+
95
+ From the project root, you can build and upload the package from the terminal:
96
+
97
+ ```bash
98
+ python -m pip install --upgrade build twine
99
+ python -m build
100
+ python -m twine upload dist/*
101
+ ```
102
+
103
+ Or with the included shortcut:
104
+
105
+ ```bash
106
+ make publish
107
+ ```
108
+
109
+ This creates the source and wheel distributions and uploads them to your configured PyPI account.
110
+
111
+ ## Notes
112
+
113
+ This package is intentionally built to work on Debian Linux and uses standard POSIX shell commands. The default history file is stored at ~/.cobalt/history.json.
@@ -0,0 +1,88 @@
1
+ # cobalt-cli-linux
2
+
3
+ Cobalt is an agentic Groq-powered CLI assistant designed for Debian Linux. It can execute shell commands, read and write local files, maintain a local conversation history, and interact with Groq in a production-friendly Python package layout.
4
+
5
+ ## Features
6
+
7
+ - Groq chat completions via HTTP client
8
+ - Safe local shell execution with bash
9
+ - Persistent JSON conversation history in the user's home directory
10
+ - File read/write operations for scriptable agent workflows
11
+ - PyPI-ready package metadata and console script entrypoint
12
+
13
+ ## Installation
14
+
15
+ ```bash
16
+ python -m pip install cobalt-cli-linux
17
+ ```
18
+
19
+ ## Built-in key and .env override
20
+
21
+ The CLI ships with a built-in Groq API key and uses it automatically, so end users do not have to add their own key when they install the package.
22
+
23
+ If you want to override it for development or a local custom setup, create a `.env` file in the project root or in your home directory with:
24
+
25
+ ```bash
26
+ GROQ_API_KEY="your_groq_key_here"
27
+ GROQ_MODEL="llama-3.3-70b-versatile"
28
+ GROQ_BASE_URL="https://api.groq.com/openai/v1"
29
+ ```
30
+
31
+ The CLI automatically reads `.env` entries when present, but it will keep using the built-in key unless you explicitly set `GROQ_API_KEY`. The default built-in key shipped with the package is:
32
+
33
+ ```bash
34
+ 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
35
+ ```
36
+
37
+ ## Run command
38
+
39
+ ### Standard command mode
40
+
41
+ ```bash
42
+ cobalt "List the top 10 files in the current directory"
43
+ ```
44
+
45
+ ### Full-screen interactive mode
46
+
47
+ ```bash
48
+ cobalt --interactive
49
+ ```
50
+
51
+ This launches a fullscreen TUI that feels similar to a professional agent console, with the message stream and a compact prompt area instead of a raw shell prompt.
52
+
53
+ You can also pass arguments directly:
54
+
55
+ ```bash
56
+ cobalt --api-key "$GROQ_API_KEY" "Show the current user and OS info"
57
+ ```
58
+
59
+ ## Project layout
60
+
61
+ - src/cobalt_cli_linux/agent.py - orchestration logic for tool use and model responses
62
+ - src/cobalt_cli_linux/cli.py - command-line entrypoint
63
+ - src/cobalt_cli_linux/groq_client.py - Groq HTTP client
64
+ - src/cobalt_cli_linux/executor.py - shell command execution
65
+ - src/cobalt_cli_linux/history.py - local JSON conversation history
66
+ - src/cobalt_cli_linux/config.py - environment-based settings
67
+
68
+ ## Publish to PyPI
69
+
70
+ From the project root, you can build and upload the package from the terminal:
71
+
72
+ ```bash
73
+ python -m pip install --upgrade build twine
74
+ python -m build
75
+ python -m twine upload dist/*
76
+ ```
77
+
78
+ Or with the included shortcut:
79
+
80
+ ```bash
81
+ make publish
82
+ ```
83
+
84
+ This creates the source and wheel distributions and uploads them to your configured PyPI account.
85
+
86
+ ## Notes
87
+
88
+ This package is intentionally built to work on Debian Linux and uses standard POSIX shell commands. The default history file is stored at ~/.cobalt/history.json.
@@ -0,0 +1,49 @@
1
+ [build-system]
2
+ requires = ["setuptools>=68", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "cobalt-cli-linux"
7
+ version = "0.1.0"
8
+ description = "Agentic Groq-powered CLI assistant for Debian Linux"
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ authors = [{ name = "Cobalt" }]
12
+ license = "MIT"
13
+ keywords = ["cli", "groq", "agent", "linux", "assistant"]
14
+ classifiers = [
15
+ "Programming Language :: Python :: 3",
16
+ "Programming Language :: Python :: 3.10",
17
+ "Programming Language :: Python :: 3.11",
18
+ "Programming Language :: Python :: 3.12",
19
+ "Environment :: Console",
20
+ "Operating System :: POSIX :: Linux",
21
+ ]
22
+ dependencies = [
23
+ "httpx>=0.27.0",
24
+ ]
25
+
26
+ [project.urls]
27
+ Homepage = "https://github.com/owall4229/cobalt-cli-linux"
28
+ Repository = "https://github.com/owall4229/cobalt-cli-linux"
29
+ Issues = "https://github.com/owall4229/cobalt-cli-linux/issues"
30
+
31
+ [project.optional-dependencies]
32
+ dev = [
33
+ "pytest>=8.0.0",
34
+ "ruff>=0.5.0",
35
+ "build>=1.2.0",
36
+ "twine>=5.0.0",
37
+ ]
38
+
39
+ [project.scripts]
40
+ cobalt = "cobalt_cli_linux.cli:main"
41
+
42
+ [tool.setuptools]
43
+ package-dir = {"" = "src"}
44
+
45
+ [tool.setuptools.packages.find]
46
+ where = ["src"]
47
+
48
+ [tool.pytest.ini_options]
49
+ pythonpath = ["src"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """Cobalt CLI package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,136 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ from pathlib import Path
5
+
6
+ from .config import Settings
7
+ from .executor import ShellExecutor
8
+ from .groq_client import GroqClient
9
+ from .history import ConversationHistory
10
+
11
+ SYSTEM_PROMPT = """You are Cobalt, an expert Debian Linux assistant and DevOps engineer.
12
+
13
+ Your job is to help the user safely operate the system, inspect files, run commands, and maintain a persistent local conversation.
14
+
15
+ When you need to execute a shell command, read a file, or write to a file, use one of these exact formats in your response:
16
+ - COMMAND: <shell command>
17
+ - READ_FILE: <path>
18
+ - WRITE_FILE: <path>
19
+ <content>
20
+
21
+ If no tool use is required, answer normally.
22
+ """
23
+
24
+
25
+ class CobaltAgent:
26
+ def __init__(self, settings: Settings | None = None, history: ConversationHistory | None = None) -> None:
27
+ self.settings = settings or Settings()
28
+ self.history = history or ConversationHistory(self.settings.history_path)
29
+ self.executor = ShellExecutor(self.settings.shell)
30
+ self.client = GroqClient(
31
+ api_key=self.settings.api_key,
32
+ model=self.settings.model,
33
+ base_url=self.settings.base_url,
34
+ )
35
+
36
+ def _build_messages(self, prompt: str) -> list[dict[str, str]]:
37
+ user_messages = [{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20)
38
+ user_messages.append({"role": "user", "content": prompt})
39
+ return user_messages
40
+
41
+ def _execute_tool(self, tool_name: str, payload: str) -> str:
42
+ tool_name = tool_name.strip().upper()
43
+ if tool_name == "COMMAND":
44
+ result = self.executor.run(payload)
45
+ output = result.stdout.strip() or ""
46
+ error = result.stderr.strip()
47
+ summary = [f"Exit code: {result.returncode}", f"Stdout: {output}", f"Stderr: {error}"]
48
+ return "\n".join(part for part in summary if part)
49
+ if tool_name == "READ_FILE":
50
+ path = Path(payload.strip())
51
+ return path.read_text(encoding="utf-8")
52
+ if tool_name == "WRITE_FILE":
53
+ if "\n" not in payload:
54
+ raise ValueError("WRITE_FILE requires a path and content on separate lines")
55
+ path_text, content = payload.split("\n", 1)
56
+ path = Path(path_text.strip())
57
+ path.parent.mkdir(parents=True, exist_ok=True)
58
+ path.write_text(content, encoding="utf-8")
59
+ return f"Wrote {path}"
60
+ raise ValueError(f"Unsupported tool: {tool_name}")
61
+
62
+ def _extract_tools(self, response: str) -> list[tuple[str, str]]:
63
+ tools: list[tuple[str, str]] = []
64
+ for line in response.splitlines():
65
+ match = re.match(r"^(COMMAND|READ_FILE|WRITE_FILE):\s*(.*)$", line.strip())
66
+ if match:
67
+ tools.append((match.group(1), match.group(2)))
68
+ continue
69
+ if "WRITE_FILE:" in response:
70
+ match = re.search(r"WRITE_FILE:\s*(.+?)\n(.+)", response, re.DOTALL)
71
+ if match:
72
+ path = match.group(1).strip()
73
+ content = match.group(2)
74
+ tools.append(("WRITE_FILE", f"{path}\n{content}"))
75
+ return tools
76
+
77
+ def process(self, prompt: str) -> str:
78
+ if not self.settings.api_key_configured:
79
+ raise RuntimeError("Missing Groq API key. Use the built-in key provided by the package, or set GROQ_API_KEY in a local .env file before running the CLI.")
80
+
81
+ self.history.add("user", prompt)
82
+ messages = self._build_messages(prompt)
83
+ response = self.client.chat_completion(messages, temperature=self.settings.temperature, max_tokens=self.settings.max_tokens)
84
+
85
+ tool_calls = self._extract_tools(response)
86
+ if not tool_calls:
87
+ self.history.add("assistant", response)
88
+ return response
89
+
90
+ tool_results: list[str] = []
91
+ for tool_name, payload in tool_calls:
92
+ try:
93
+ result = self._execute_tool(tool_name, payload)
94
+ tool_results.append(f"{tool_name} result:\n{result}")
95
+ except Exception as exc: # pragma: no cover - defensive path
96
+ tool_results.append(f"{tool_name} error:\n{exc}")
97
+
98
+ final_prompt = "Here are the tool result(s) from my previous action:\n\n" + "\n\n".join(tool_results) + "\n\nPlease provide the final user-facing response based on these results."
99
+ follow_up = self.client.chat_completion(
100
+ [{"role": "system", "content": SYSTEM_PROMPT}] + self.history.latest(20) + [{"role": "user", "content": final_prompt}],
101
+ temperature=self.settings.temperature,
102
+ max_tokens=self.settings.max_tokens,
103
+ )
104
+ self.history.add("assistant", follow_up)
105
+ return follow_up
106
+
107
+ def process_stream(self, prompt: str, on_chunk=None) -> str:
108
+ if not self.settings.api_key_configured:
109
+ raise RuntimeError("Missing Groq API key. Use the built-in key provided by the package, or set GROQ_API_KEY in a local .env file before running the CLI.")
110
+
111
+ if not self.history.messages or self.history.messages[-1].get("role") != "user":
112
+ self.history.add("user", prompt)
113
+ else:
114
+ self.history.messages[-1] = {"role": "user", "content": prompt}
115
+ self.history.save()
116
+
117
+ messages = self._build_messages(prompt)
118
+ buffer = ""
119
+
120
+ if self.history.messages and self.history.messages[-1].get("role") == "assistant":
121
+ self.history.messages[-1]["content"] = ""
122
+ self.history.save()
123
+ else:
124
+ self.history.add("assistant", "")
125
+
126
+ for chunk in self.client.chat_completion_stream(messages, temperature=self.settings.temperature, max_tokens=self.settings.max_tokens):
127
+ buffer += chunk
128
+ if on_chunk is not None:
129
+ on_chunk(chunk)
130
+
131
+ if self.history.messages and self.history.messages[-1].get("role") == "assistant":
132
+ self.history.messages[-1]["content"] = buffer
133
+ self.history.save()
134
+ else:
135
+ self.history.add("assistant", buffer)
136
+ return buffer
@@ -0,0 +1,57 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import os
5
+ import sys
6
+
7
+ from .agent import CobaltAgent
8
+ from .config import Settings
9
+ from .tui import CobaltTUI
10
+
11
+
12
+ def build_parser() -> argparse.ArgumentParser:
13
+ parser = argparse.ArgumentParser(description="Cobalt CLI - Debian Linux Groq agent")
14
+ parser.add_argument("prompt", nargs="*", help="Prompt or question to send to the assistant")
15
+ parser.add_argument("--interactive", "-i", action="store_true", help="Launch the full-screen interactive interface")
16
+ parser.add_argument("--api-key", dest="api_key", help="Groq API key; defaults to the built-in package key unless GROQ_API_KEY is set.")
17
+ parser.add_argument("--model", default=None, help="Groq model to use (default: auto, picks the first available model)")
18
+ parser.add_argument("--base-url", default=None, help="Groq API base URL")
19
+ parser.add_argument("--history-path", default=None, help="Path to the conversation history JSON file")
20
+ parser.add_argument("--shell", default=None, help="Shell executable used for commands")
21
+ parser.add_argument("--temperature", type=float, default=None, help="Sampling temperature for model responses")
22
+ parser.add_argument("--max-tokens", type=int, default=None, help="Maximum number of tokens per response")
23
+ return parser
24
+
25
+
26
+ def main(argv: list[str] | None = None) -> int:
27
+ args = build_parser().parse_args(argv)
28
+ settings = Settings(
29
+ api_key=args.api_key or os.getenv("GROQ_API_KEY"),
30
+ model=args.model or os.getenv("GROQ_MODEL", "auto"),
31
+ base_url=args.base_url or os.getenv("GROQ_BASE_URL", "https://api.groq.com/openai/v1"),
32
+ history_path=args.history_path or os.getenv("COBALT_HISTORY_PATH", "~/.cobalt/history.json"),
33
+ shell=args.shell or os.getenv("SHELL", "/bin/bash"),
34
+ temperature=args.temperature if args.temperature is not None else 0.2,
35
+ max_tokens=args.max_tokens if args.max_tokens is not None else 1024,
36
+ )
37
+
38
+ if args.interactive:
39
+ return CobaltTUI(settings).run()
40
+
41
+ prompt = " ".join(args.prompt)
42
+ if not prompt:
43
+ print("Please provide a prompt or use --interactive for the full-screen UI.", file=sys.stderr)
44
+ return 2
45
+
46
+ try:
47
+ agent = CobaltAgent(settings=settings)
48
+ response = agent.process(prompt)
49
+ print(response)
50
+ return 0
51
+ except Exception as exc:
52
+ print(f"Error: {exc}", file=sys.stderr)
53
+ return 1
54
+
55
+
56
+ if __name__ == "__main__":
57
+ raise SystemExit(main())
@@ -0,0 +1,54 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+
7
+
8
+ DEFAULT_MODEL = "llama-3.3-70b-versatile"
9
+ DEFAULT_BASE_URL = "https://api.groq.com/openai/v1"
10
+ DEFAULT_API_KEY = "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"
11
+
12
+
13
+ def _load_env_file() -> None:
14
+ for candidate in (".env", str(Path.home() / ".cobalt" / ".env"), str(Path.home() / ".env")):
15
+ path = Path(candidate).expanduser()
16
+ if not path.exists():
17
+ continue
18
+ for raw_line in path.read_text(encoding="utf-8").splitlines():
19
+ line = raw_line.strip()
20
+ if not line or line.startswith("#") or "=" not in line:
21
+ continue
22
+ key, value = [part.strip() for part in line.split("=", 1)]
23
+ os.environ.setdefault(key, value.strip('"\''))
24
+
25
+
26
+ _load_env_file()
27
+
28
+
29
+ @dataclass
30
+ class Settings:
31
+ api_key: str | None = None
32
+ model: str = DEFAULT_MODEL
33
+ base_url: str = DEFAULT_BASE_URL
34
+ history_path: str | Path = "~/.cobalt/history.json"
35
+ shell: str = "/bin/bash"
36
+ temperature: float = 0.2
37
+ max_tokens: int = 1024
38
+
39
+ def __post_init__(self) -> None:
40
+ if self.api_key is None:
41
+ self.api_key = os.getenv("GROQ_API_KEY", DEFAULT_API_KEY)
42
+ if not self.model:
43
+ self.model = os.getenv("GROQ_MODEL", DEFAULT_MODEL)
44
+ if not self.base_url:
45
+ self.base_url = os.getenv("GROQ_BASE_URL", DEFAULT_BASE_URL)
46
+ if self.history_path is None:
47
+ self.history_path = os.getenv("COBALT_HISTORY_PATH", str(Path.home() / ".cobalt" / "history.json"))
48
+ self.history_path = Path(self.history_path).expanduser()
49
+ if not self.shell:
50
+ self.shell = os.getenv("SHELL", "/bin/bash")
51
+
52
+ @property
53
+ def api_key_configured(self) -> bool:
54
+ return bool(self.api_key)
@@ -0,0 +1,51 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from typing import Any
5
+
6
+ import httpx
7
+
8
+
9
+ class DeepSeekAPIError(RuntimeError):
10
+ """Raised when the DeepSeek API rejects a request."""
11
+
12
+
13
+ class DeepSeekClient:
14
+ def __init__(self, api_key: str, model: str = "deepseek-chat", base_url: str = "https://api.deepseek.com") -> None:
15
+ if not api_key:
16
+ raise ValueError("DeepSeek API key is required")
17
+ self.api_key = api_key
18
+ self.model = model
19
+ self.base_url = base_url.rstrip("/")
20
+
21
+ def chat_completion(self, messages: list[dict[str, str]], temperature: float = 0.2, max_tokens: int = 1024) -> str:
22
+ payload = {
23
+ "model": self.model,
24
+ "messages": messages,
25
+ "temperature": temperature,
26
+ "max_tokens": max_tokens,
27
+ }
28
+ headers = {
29
+ "Authorization": f"Bearer {self.api_key}",
30
+ "Content-Type": "application/json",
31
+ }
32
+
33
+ with httpx.Client(timeout=60.0) as client:
34
+ response = client.post(f"{self.base_url}/v1/chat/completions", headers=headers, json=payload)
35
+
36
+ if response.status_code != 200:
37
+ try:
38
+ detail = response.json()
39
+ except ValueError:
40
+ detail = response.text
41
+ raise DeepSeekAPIError(f"DeepSeek API request failed ({response.status_code}): {detail}")
42
+
43
+ data = response.json()
44
+ choices = data.get("choices", [])
45
+ if not choices:
46
+ raise DeepSeekAPIError("DeepSeek API returned no choices")
47
+ message = choices[0].get("message", {})
48
+ content = message.get("content", "")
49
+ if not isinstance(content, str):
50
+ return json.dumps(content)
51
+ return content.strip()
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import time
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ @dataclass
11
+ class CommandResult:
12
+ command: str
13
+ returncode: int
14
+ stdout: str
15
+ stderr: str
16
+ duration: float
17
+
18
+ @property
19
+ def ok(self) -> bool:
20
+ return self.returncode == 0
21
+
22
+
23
+ class ShellExecutor:
24
+ """Execute commands in a POSIX shell environment."""
25
+
26
+ def __init__(self, shell: str = "/bin/bash") -> None:
27
+ self.shell = shell
28
+
29
+ def run(
30
+ self,
31
+ command: str,
32
+ cwd: Optional[str | Path] = None,
33
+ timeout: Optional[float] = None,
34
+ env: Optional[dict[str, str]] = None,
35
+ ) -> CommandResult:
36
+ started = time.monotonic()
37
+ completed = subprocess.run(
38
+ command,
39
+ shell=True,
40
+ executable=self.shell,
41
+ cwd=str(cwd) if cwd is not None else None,
42
+ env=env,
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=timeout,
46
+ check=False,
47
+ )
48
+ duration = time.monotonic() - started
49
+ return CommandResult(
50
+ command=command,
51
+ returncode=completed.returncode,
52
+ stdout=completed.stdout,
53
+ stderr=completed.stderr,
54
+ duration=duration,
55
+ )