thetaterm 0.1.1__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,201 @@
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
thetaterm-0.1.1/NOTICE ADDED
@@ -0,0 +1,7 @@
1
+ Copyright 2026 ThetaPlex
2
+
3
+ Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at
4
+
5
+ http://www.apache.org/licenses/LICENSE-2.0
6
+
7
+ Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License.
@@ -0,0 +1,109 @@
1
+ Metadata-Version: 2.4
2
+ Name: thetaterm
3
+ Version: 0.1.1
4
+ Summary: Supercharge your terminal with AI
5
+ Keywords: shell,terminal,cli,llm,ollama,natural-language
6
+ Author: ThetaPlex
7
+ Author-email: ThetaPlex <support@thetaplex.com>
8
+ License-Expression: Apache-2.0
9
+ License-File: LICENSE
10
+ License-File: NOTICE
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: System Administrators
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Operating System :: POSIX :: Linux
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: System :: Shells
21
+ Classifier: Topic :: Utilities
22
+ Requires-Dist: python-dotenv>=1.2.3
23
+ Requires-Dist: rich>=14.1.0
24
+ Requires-Dist: typer>=0.17.3
25
+ Requires-Python: >=3.13
26
+ Project-URL: Repository, https://github.com/thetaplex/thetaterm
27
+ Project-URL: Documentation, https://github.com/thetaplex/thetaterm/tree/main/docs
28
+ Project-URL: Issues, https://github.com/thetaplex/thetaterm/issues
29
+ Project-URL: Changelog, https://github.com/thetaplex/thetaterm/blob/main/CHANGELOG.md
30
+ Description-Content-Type: text/markdown
31
+
32
+ # Thetaterm
33
+
34
+ Supercharge your terminal with AI: describe what you want, get a shell command
35
+ that is correct for *your* system, confirm, run.
36
+
37
+ ## Why
38
+
39
+ Small local models know what `find` or `sed` does, but not which options your
40
+ copy has. GNU, BSD and BusyBox tools differ, so a command that's right on Linux
41
+ can fail on a Mac. Thetaterm detects your OS and whether your tools are GNU,
42
+ BSD or BusyBox, and gives the model the installed tool's `man` page. Every
43
+ command is checked before you're asked to run it. It works with any model
44
+ served over an OpenAI-compatible API, including ones on your own machine.
45
+
46
+ ## Requirements
47
+
48
+ - macOS or Linux
49
+ - [uv](https://docs.astral.sh/uv/getting-started/installation/). It installs
50
+ Python 3.13 or later for you if needed.
51
+ - A model served over an OpenAI-compatible API. The default is `gemma4:e4b` on
52
+ [Ollama](https://ollama.com/download): `ollama pull gemma4:e4b`
53
+
54
+ ## Install
55
+
56
+ ```bash
57
+ uv tool install thetaterm
58
+ ```
59
+
60
+ ## Quickstart
61
+
62
+ ```bash
63
+ tterm -q "list files in this directory, largest first"
64
+ ```
65
+
66
+ ```
67
+ $ ls -lS .
68
+ Run it? [Y/n]:
69
+ ```
70
+
71
+ New to it? Follow the [tutorial](https://github.com/thetaplex/thetaterm/blob/main/docs/tutorials/first-command.md).
72
+
73
+ ## Usage
74
+
75
+ ```bash
76
+ tterm # interactive
77
+ tterm -q "find files modified in the last 2 days"
78
+ tterm -m qwen2.5-coder:3b -q "replace foo with bar in notes.txt in place" # another model
79
+ tterm -y -q "show git status" # run without confirming
80
+ tterm --think -q "show the date 30 days from now" # model reasons first, about 2x slower
81
+ ```
82
+
83
+ > **Warning:** `-y` runs whatever the model writes, unreviewed. In interactive
84
+ > mode that applies to every line you type. Commands pass a syntax check, not a
85
+ > safety check.
86
+
87
+ Commands that look like they reach outside the current directory (absolute,
88
+ `~` or `..` paths, variables such as `$HOME`, `cd` home or back, or `sudo`) are shown in red and always ask first,
89
+ defaulting to no, even with `-y`. This is a pattern match, not a sandbox. See
90
+ the [safety model](https://github.com/thetaplex/thetaterm/blob/main/docs/explanation/safety.md).
91
+
92
+ Settings (`THETATERM_MODEL`, `THETATERM_BASE_URL`, `THETATERM_API_KEY`) are
93
+ read from the environment or `~/.config/thetaterm/.env`, never from a `.env`
94
+ in the current directory. See the
95
+ [command-line reference](https://github.com/thetaplex/thetaterm/blob/main/docs/reference/cli.md) and
96
+ [how to use another model server](https://github.com/thetaplex/thetaterm/blob/main/docs/how-to/use-another-model-server.md).
97
+
98
+ ## Documentation
99
+
100
+ - [Documentation index](https://github.com/thetaplex/thetaterm/blob/main/docs/README.md): tutorial, how-to guides, reference,
101
+ explanation and design decisions
102
+ - [Contributing](https://github.com/thetaplex/thetaterm/blob/main/CONTRIBUTING.md): development setup, needs
103
+ [just](https://just.systems/man/en/packages.html)
104
+ - [Security policy](https://github.com/thetaplex/thetaterm/blob/main/SECURITY.md)
105
+ - [Changelog](https://github.com/thetaplex/thetaterm/blob/main/CHANGELOG.md)
106
+
107
+ ## License
108
+
109
+ [Apache-2.0](https://github.com/thetaplex/thetaterm/blob/main/LICENSE)
@@ -0,0 +1,78 @@
1
+ # Thetaterm
2
+
3
+ Supercharge your terminal with AI: describe what you want, get a shell command
4
+ that is correct for *your* system, confirm, run.
5
+
6
+ ## Why
7
+
8
+ Small local models know what `find` or `sed` does, but not which options your
9
+ copy has. GNU, BSD and BusyBox tools differ, so a command that's right on Linux
10
+ can fail on a Mac. Thetaterm detects your OS and whether your tools are GNU,
11
+ BSD or BusyBox, and gives the model the installed tool's `man` page. Every
12
+ command is checked before you're asked to run it. It works with any model
13
+ served over an OpenAI-compatible API, including ones on your own machine.
14
+
15
+ ## Requirements
16
+
17
+ - macOS or Linux
18
+ - [uv](https://docs.astral.sh/uv/getting-started/installation/). It installs
19
+ Python 3.13 or later for you if needed.
20
+ - A model served over an OpenAI-compatible API. The default is `gemma4:e4b` on
21
+ [Ollama](https://ollama.com/download): `ollama pull gemma4:e4b`
22
+
23
+ ## Install
24
+
25
+ ```bash
26
+ uv tool install thetaterm
27
+ ```
28
+
29
+ ## Quickstart
30
+
31
+ ```bash
32
+ tterm -q "list files in this directory, largest first"
33
+ ```
34
+
35
+ ```
36
+ $ ls -lS .
37
+ Run it? [Y/n]:
38
+ ```
39
+
40
+ New to it? Follow the [tutorial](https://github.com/thetaplex/thetaterm/blob/main/docs/tutorials/first-command.md).
41
+
42
+ ## Usage
43
+
44
+ ```bash
45
+ tterm # interactive
46
+ tterm -q "find files modified in the last 2 days"
47
+ tterm -m qwen2.5-coder:3b -q "replace foo with bar in notes.txt in place" # another model
48
+ tterm -y -q "show git status" # run without confirming
49
+ tterm --think -q "show the date 30 days from now" # model reasons first, about 2x slower
50
+ ```
51
+
52
+ > **Warning:** `-y` runs whatever the model writes, unreviewed. In interactive
53
+ > mode that applies to every line you type. Commands pass a syntax check, not a
54
+ > safety check.
55
+
56
+ Commands that look like they reach outside the current directory (absolute,
57
+ `~` or `..` paths, variables such as `$HOME`, `cd` home or back, or `sudo`) are shown in red and always ask first,
58
+ defaulting to no, even with `-y`. This is a pattern match, not a sandbox. See
59
+ the [safety model](https://github.com/thetaplex/thetaterm/blob/main/docs/explanation/safety.md).
60
+
61
+ Settings (`THETATERM_MODEL`, `THETATERM_BASE_URL`, `THETATERM_API_KEY`) are
62
+ read from the environment or `~/.config/thetaterm/.env`, never from a `.env`
63
+ in the current directory. See the
64
+ [command-line reference](https://github.com/thetaplex/thetaterm/blob/main/docs/reference/cli.md) and
65
+ [how to use another model server](https://github.com/thetaplex/thetaterm/blob/main/docs/how-to/use-another-model-server.md).
66
+
67
+ ## Documentation
68
+
69
+ - [Documentation index](https://github.com/thetaplex/thetaterm/blob/main/docs/README.md): tutorial, how-to guides, reference,
70
+ explanation and design decisions
71
+ - [Contributing](https://github.com/thetaplex/thetaterm/blob/main/CONTRIBUTING.md): development setup, needs
72
+ [just](https://just.systems/man/en/packages.html)
73
+ - [Security policy](https://github.com/thetaplex/thetaterm/blob/main/SECURITY.md)
74
+ - [Changelog](https://github.com/thetaplex/thetaterm/blob/main/CHANGELOG.md)
75
+
76
+ ## License
77
+
78
+ [Apache-2.0](https://github.com/thetaplex/thetaterm/blob/main/LICENSE)
@@ -0,0 +1,65 @@
1
+ [project]
2
+ name = "thetaterm"
3
+ version = "0.1.1"
4
+ description = "Supercharge your terminal with AI"
5
+ readme = "README.md"
6
+ license = "Apache-2.0"
7
+ license-files = [
8
+ "LICENSE",
9
+ "NOTICE",
10
+ ]
11
+ requires-python = ">=3.13"
12
+ keywords = [
13
+ "shell",
14
+ "terminal",
15
+ "cli",
16
+ "llm",
17
+ "ollama",
18
+ "natural-language",
19
+ ]
20
+ classifiers = [
21
+ "Development Status :: 3 - Alpha",
22
+ "Environment :: Console",
23
+ "Intended Audience :: Developers",
24
+ "Intended Audience :: System Administrators",
25
+ "Operating System :: MacOS",
26
+ "Operating System :: POSIX :: Linux",
27
+ "Programming Language :: Python :: 3",
28
+ "Programming Language :: Python :: 3 :: Only",
29
+ "Programming Language :: Python :: 3.13",
30
+ "Topic :: System :: Shells",
31
+ "Topic :: Utilities",
32
+ ]
33
+ dependencies = [
34
+ "python-dotenv>=1.2.3",
35
+ "rich>=14.1.0",
36
+ "typer>=0.17.3",
37
+ ]
38
+
39
+ [[project.authors]]
40
+ name = "ThetaPlex"
41
+ email = "support@thetaplex.com"
42
+
43
+ [project.urls]
44
+ Repository = "https://github.com/thetaplex/thetaterm"
45
+ Documentation = "https://github.com/thetaplex/thetaterm/tree/main/docs"
46
+ Issues = "https://github.com/thetaplex/thetaterm/issues"
47
+ Changelog = "https://github.com/thetaplex/thetaterm/blob/main/CHANGELOG.md"
48
+
49
+ [project.scripts]
50
+ tterm = "thetaterm.cli:app"
51
+
52
+ [dependency-groups]
53
+ dev = [
54
+ "opentelemetry-exporter-otlp-proto-http>=1.45.0",
55
+ "opentelemetry-sdk>=1.45.0",
56
+ "pytest>=8.4.1",
57
+ "ruff>=0.16.9",
58
+ ]
59
+
60
+ [build-system]
61
+ requires = ["uv_build>=0.8,<0.10"]
62
+ build-backend = "uv_build"
63
+
64
+ [tool.uv.build-backend]
65
+ module-root = ""
@@ -0,0 +1,54 @@
1
+ [project]
2
+ name = "thetaterm"
3
+ version = "0.1.1"
4
+ description = "Supercharge your terminal with AI"
5
+ readme = "README.md"
6
+ authors = [{ name = "ThetaPlex", email = "support@thetaplex.com" }]
7
+ license = "Apache-2.0"
8
+ license-files = ["LICENSE", "NOTICE"]
9
+ requires-python = ">=3.13"
10
+ keywords = ["shell", "terminal", "cli", "llm", "ollama", "natural-language"]
11
+ # no License :: classifier: license (SPDX) replaces it, and PyPI rejects both
12
+ classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Console",
15
+ "Intended Audience :: Developers",
16
+ "Intended Audience :: System Administrators",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Programming Language :: Python :: 3 :: Only",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Topic :: System :: Shells",
23
+ "Topic :: Utilities",
24
+ ]
25
+ dependencies = [
26
+ "python-dotenv>=1.2.3",
27
+ "rich>=14.1.0",
28
+ "typer>=0.17.3",
29
+ ]
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "opentelemetry-exporter-otlp-proto-http>=1.45.0",
34
+ "opentelemetry-sdk>=1.45.0",
35
+ "pytest>=8.4.1",
36
+ "ruff>=0.16.9",
37
+ ]
38
+
39
+ [project.urls]
40
+ Repository = "https://github.com/thetaplex/thetaterm"
41
+ Documentation = "https://github.com/thetaplex/thetaterm/tree/main/docs"
42
+ Issues = "https://github.com/thetaplex/thetaterm/issues"
43
+ Changelog = "https://github.com/thetaplex/thetaterm/blob/main/CHANGELOG.md"
44
+
45
+ [project.scripts]
46
+ tterm = "thetaterm.cli:app"
47
+
48
+
49
+ [build-system]
50
+ requires = ["uv_build>=0.8,<0.10"]
51
+ build-backend = "uv_build"
52
+
53
+ [tool.uv.build-backend]
54
+ module-root = ""
File without changes
@@ -0,0 +1,331 @@
1
+ """Turn natural language into a shell command using the OS's own docs.
2
+
3
+ Small models can't memorise the differences between GNU, BSD and BusyBox
4
+ userlands, so we don't ask them to: the model suggests commands, the machine
5
+ says which are installed, and the man page of the chosen one goes into the
6
+ prompt. The model only has to read and fill in.
7
+ """
8
+
9
+ import json
10
+ import math
11
+ import os
12
+ import platform
13
+ import re
14
+ import shlex
15
+ import shutil
16
+ import subprocess
17
+ import urllib.error
18
+ import urllib.request
19
+
20
+ # problem() and outside() read commands as sh words, so only shells that parse like sh
21
+ POSIX_SHELLS = {"sh", "bash", "zsh", "ksh", "dash"}
22
+
23
+
24
+ def user_shell() -> str:
25
+ """The login shell if it parses like sh, else bash, else /bin/sh."""
26
+ login = os.environ.get("SHELL", "")
27
+ if os.path.basename(login) in POSIX_SHELLS and shutil.which(login):
28
+ return login
29
+ return shutil.which("bash") or "/bin/sh"
30
+
31
+
32
+ SHELL = user_shell()
33
+ _STOPWORDS = (
34
+ "a an the to of in on for from with and or all any my me i this that these those "
35
+ "is are be it its by as at into using use how what which show get make do"
36
+ )
37
+ STOPWORDS = set(_STOPWORDS.split())
38
+ # `!` negates a pipeline in command position; elsewhere (find ! -name) it is an argument
39
+ PREFIXES = {"sudo", "env", "time", "nohup", "command", "!"}
40
+ MAX_REFERENCE_CHARS = 3500
41
+ RETRIES = 2
42
+ # devices that hold no files; anything else under /dev (disks) counts as outside
43
+ SAFE_DEVICE = re.compile(r"/dev/(null|zero|u?random|tty|std(in|out|err)|fd/\d+)")
44
+ # variables that can't name a directory
45
+ SAFE_VARIABLES = {"PWD", "USER", "LOGNAME", "UID", "RANDOM", "IFS"}
46
+
47
+
48
+ def sh(args: list[str], timeout: int = 10) -> subprocess.CompletedProcess:
49
+ env = os.environ | {"PAGER": "cat", "MANPAGER": "cat", "MANWIDTH": "100"}
50
+ try:
51
+ return subprocess.run(
52
+ args, capture_output=True, text=True, timeout=timeout, env=env, check=False
53
+ )
54
+ except (OSError, subprocess.TimeoutExpired) as e:
55
+ return subprocess.CompletedProcess(args, 1, "", str(e))
56
+
57
+
58
+ def environment() -> str:
59
+ """One line describing the OS and which userland flavour its tools are."""
60
+ system = platform.system()
61
+ if system == "Darwin":
62
+ name = f"macOS {platform.mac_ver()[0]}"
63
+ elif system == "Linux":
64
+ try:
65
+ name = platform.freedesktop_os_release().get("PRETTY_NAME", "Linux")
66
+ except OSError:
67
+ name = "Linux"
68
+ else:
69
+ name = f"{system} {platform.release()}"
70
+
71
+ ls = shutil.which("ls") or ""
72
+ if os.path.realpath(ls).endswith("busybox"):
73
+ userland = "BusyBox"
74
+ elif "GNU" in sh(["ls", "--version"]).stdout:
75
+ userland = "GNU coreutils"
76
+ else:
77
+ userland = "BSD"
78
+ return f"{name}, {userland} userland, {os.path.basename(SHELL)} shell"
79
+
80
+
81
+ def keywords(query: str) -> list[str]:
82
+ words = re.findall(r"[a-z0-9][a-z0-9._+-]*", query.lower())
83
+ # ponytail: crude plural stripping, a real stemmer if excerpts miss too much
84
+ return [
85
+ w[:-1] if len(w) > 3 and w.endswith("s") else w
86
+ for w in words
87
+ if w not in STOPWORDS
88
+ ]
89
+
90
+
91
+ def reference(command: str, query: str) -> str:
92
+ """The parts of a command's man page relevant to the query.
93
+
94
+ Never `command --help`: a program that doesn't honour it would run before
95
+ the user agreed to anything (ADR 0005).
96
+ """
97
+ text = re.sub(r".\x08", "", sh(["man", command]).stdout) # strip overstrike bold
98
+ return excerpt(text, query)
99
+
100
+
101
+ def entries(text: str) -> list[tuple[int, str]]:
102
+ """Split docs into (first line number, text) paragraphs and option entries."""
103
+ found, current, start, indent, blank = [], [], 0, 0, False
104
+ for n, line in enumerate(text.splitlines()):
105
+ line = line.rstrip()
106
+ body = line.lstrip()
107
+ if not body:
108
+ blank = True
109
+ continue
110
+ depth = len(line) - len(body)
111
+ # an option at or left of the entry's own indent starts a new one, even
112
+ # without a blank line between (--help output rarely has them); after a
113
+ # blank line only text indented under an option continues it (-type's list)
114
+ under_option = current[:1] and current[0].lstrip().startswith("-")
115
+ if current and (
116
+ (blank and not (under_option and depth > indent))
117
+ or (body.startswith("-") and depth <= indent)
118
+ ):
119
+ found.append((start, "\n".join(current)))
120
+ current = []
121
+ if not current:
122
+ start, indent = n, depth
123
+ current.append(line)
124
+ blank = False
125
+ if current:
126
+ found.append((start, "\n".join(current)))
127
+ return found
128
+
129
+
130
+ def excerpt(text: str, query: str) -> str:
131
+ """Whole entries that best match the query, rare words counting most."""
132
+ chunks = entries(text)
133
+ words = set(keywords(query))
134
+ matches = [{w for w in words if w in chunk.lower()} for _, chunk in chunks]
135
+ df = {w: sum(w in m for m in matches) for w in words}
136
+ idf = {w: math.log(len(chunks) / n) for w, n in df.items() if n}
137
+
138
+ def score(i: int) -> float:
139
+ if chunks[i][0] < 20: # NAME + SYNOPSIS
140
+ return math.inf
141
+ return sum(idf[w] for w in matches[i])
142
+
143
+ keep, used = set(), 0
144
+ # stable sort: ties go to whatever comes first on the page
145
+ for i in sorted(range(len(chunks)), key=score, reverse=True):
146
+ size = len(chunks[i][1]) + 1
147
+ if score(i) and used + size <= MAX_REFERENCE_CHARS:
148
+ keep.add(i)
149
+ used += size
150
+ return "\n".join(chunks[i][1] for i in sorted(keep))
151
+
152
+
153
+ def clean(response: str) -> str:
154
+ """Pull the bare command out of whatever the model wrapped it in."""
155
+ # a fenced block beats any prose around it
156
+ fenced = re.search(r"```\w*\n(.*?)```", response, re.DOTALL)
157
+ text = fenced[1] if fenced else re.sub(r"```\w*", "", response).strip()
158
+ line = next((line.strip() for line in text.splitlines() if line.strip()), "")
159
+ # some models echo the prompt's "Command:" label
160
+ line = re.sub(r"^command:\s*", "", line, flags=re.IGNORECASE).strip("`")
161
+ for prefix in ("$ ", "# ", "> "):
162
+ line = line.removeprefix(prefix)
163
+ return line.strip()
164
+
165
+
166
+ def tokens(command: str) -> list[str]:
167
+ """Shell words and operators; raises ValueError on unbalanced quotes."""
168
+ lexer = shlex.shlex(command, posix=True, punctuation_chars=True)
169
+ lexer.whitespace_split = True
170
+ return list(lexer)
171
+
172
+
173
+ def outside(command: str) -> str | None:
174
+ """Why this command may reach beyond the current directory, or None.
175
+
176
+ ponytail: pattern match on the words, catches the model's honest mistakes, not
177
+ obfuscation (`cat $(printf '\\x2f')etc`); a real sandbox if that matters.
178
+ """
179
+ words = tokens(command)
180
+ for i, token in enumerate(words):
181
+ # --output=/tmp/x, of=/dev/sda, -C/tmp
182
+ path = re.sub(r"^-[A-Za-z]", "", token.split("=", 1)[-1])
183
+ if token == "sudo":
184
+ return "runs as root"
185
+ # bare `cd` goes home, `cd -` and `popd` go back to an earlier directory
186
+ target = words[i + 1] if i + 1 < len(words) else ";"
187
+ if token in {"cd", "pushd", "popd"} and target in {
188
+ "-",
189
+ ";",
190
+ "&&",
191
+ "||",
192
+ "&",
193
+ ")",
194
+ }:
195
+ return f"{token} leaves for the home or previous directory"
196
+ if path.startswith("~"):
197
+ return f"home path {token}"
198
+ if path.startswith("/") and not SAFE_DEVICE.fullmatch(path):
199
+ return f"absolute path {token}"
200
+ if ".." in path.split("/"):
201
+ return f"parent path {token}"
202
+ # any variable may hold a path; single quotes don't expand, loop and read
203
+ # variables are the command's own
204
+ unquoted = re.sub(r"'[^']*'", "", command)
205
+ own = set(re.findall(r"\bfor\s+(\w+)\s+in\b", unquoted))
206
+ own |= set(re.findall(r"\bread\s+(?:-\w+\s+)*(\w+)", unquoted))
207
+ own |= set(re.findall(r"(?:^|[\s;&|(])([A-Za-z_]\w*)=", unquoted))
208
+ for name in re.findall(r"\$\{?([A-Za-z_]\w*)", unquoted):
209
+ if name not in own | SAFE_VARIABLES:
210
+ return f"uses ${name}"
211
+ return None
212
+
213
+
214
+ def problem(command: str) -> str | None:
215
+ """Why this command would fail before it even runs, or None."""
216
+ if not command:
217
+ return "empty command"
218
+ # stderr, not the exit status: zsh -n returns 1 for a valid `! cmd`
219
+ r = sh([SHELL, "-n", "-c", command])
220
+ if r.returncode and r.stderr.strip():
221
+ return r.stderr.strip()
222
+ try:
223
+ # shlex unquotes find's `\;` into a bare `;`, which would read as a separator
224
+ words = tokens(re.sub(r"\\;|';'|\";\"", "_", command))
225
+ except ValueError as e:
226
+ return str(e)
227
+ at_command = True
228
+ for token in words:
229
+ if token in {"|", "||", "&&", ";", "&", "(", "{"}:
230
+ at_command = True
231
+ elif at_command:
232
+ if re.match(r"\w+=", token) or token in PREFIXES:
233
+ continue
234
+ if sh([SHELL, "-c", f"command -v {shlex.quote(token)}"]).returncode:
235
+ return f"{token}: command not found"
236
+ at_command = False
237
+ return None
238
+
239
+
240
+ class Agent:
241
+ """Talks to any OpenAI-compatible chat endpoint (Ollama, llama.cpp, vLLM, ...)."""
242
+
243
+ def __init__(
244
+ self,
245
+ model: str,
246
+ base_url: str,
247
+ api_key: str | None = None,
248
+ thinking: bool = False,
249
+ ):
250
+ self.model = model
251
+ self.url = base_url.rstrip("/") + "/chat/completions"
252
+ self.headers = {"Content-Type": "application/json"}
253
+ if api_key:
254
+ self.headers["Authorization"] = f"Bearer {api_key}"
255
+ self.env = environment()
256
+ # Thinking models reason for minutes over a man page to write one command.
257
+ # Dropped if the server rejects it.
258
+ self.thinking = thinking
259
+ self.thinking_off = {} if thinking else {"reasoning_effort": "none"}
260
+
261
+ def ask(self, prompt: str, max_tokens: int | None = None) -> str:
262
+ body = {
263
+ "model": self.model,
264
+ "messages": [{"role": "user", "content": prompt}],
265
+ "temperature": 0,
266
+ } | self.thinking_off
267
+ # reasoning counts against max_tokens, so a cap would cut thinking short
268
+ if max_tokens and not self.thinking:
269
+ body["max_tokens"] = max_tokens
270
+ request = urllib.request.Request(
271
+ self.url, json.dumps(body).encode(), self.headers
272
+ )
273
+ try:
274
+ with urllib.request.urlopen(request, timeout=300) as r:
275
+ reply = json.load(r)
276
+ except urllib.error.HTTPError as e:
277
+ detail = e.read().decode()[:200]
278
+ if e.code == 400 and self.thinking_off and "reasoning" in detail:
279
+ self.thinking_off = {}
280
+ return self.ask(prompt, max_tokens)
281
+ raise RuntimeError(f"{self.url}: {e.code} {detail}")
282
+ except OSError as e:
283
+ raise RuntimeError(f"cannot reach {self.url}: {getattr(e, 'reason', e)}")
284
+ except ValueError:
285
+ raise RuntimeError(f"{self.url}: reply is not JSON") from None
286
+ try:
287
+ return reply["choices"][0]["message"]["content"] or ""
288
+ except (KeyError, IndexError, TypeError):
289
+ # some servers answer 200 with {"error": ...}
290
+ raise RuntimeError(
291
+ f"{self.url}: unexpected reply {json.dumps(reply)[:200]}"
292
+ ) from None
293
+
294
+ def choose(self, query: str) -> str | None:
295
+ """The main command for the task: the model suggests, the OS confirms."""
296
+ answer = self.ask(
297
+ f"System: {self.env}\nTask: {query}\n"
298
+ "Which commands can do this? List up to 5 command names only, one per line, best first.",
299
+ # some models repeat the last name until the context runs out
300
+ max_tokens=60,
301
+ )
302
+ # one name per line or comma: "1. `ifconfig` - shows ..." -> ifconfig
303
+ for item in re.split(r"[\n,]", answer):
304
+ name = next(iter(re.findall(r"[A-Za-z][\w.+-]*", item)), None)
305
+ if name and shutil.which(name):
306
+ return name
307
+ return None
308
+
309
+ def generate(self, query: str) -> str:
310
+ """Best command for the query; raises RuntimeError if none passes checks."""
311
+ prompt = f"System: {self.env}\n"
312
+ # a thinking model works out the platform's flags itself; the extra
313
+ # round trip and man page only slow it down (evals: 49/50 vs 48/50)
314
+ chosen = None if self.thinking else self.choose(query)
315
+ if chosen:
316
+ ref = reference(chosen, query)
317
+ prompt += f"\nReference for `{chosen}` on this system:\n{ref}\n"
318
+ prompt += (
319
+ f"\nTask: {query}\nWrite one {os.path.basename(SHELL)} command for this system. "
320
+ "Use real paths, never placeholders like /path/to; if the task names no location, use the current directory (.). "
321
+ "Reply with the command only.\n"
322
+ )
323
+
324
+ attempt = prompt
325
+ for _ in range(RETRIES + 1):
326
+ command = clean(self.ask(attempt + "Command:"))
327
+ error = problem(command)
328
+ if error is None:
329
+ return command
330
+ attempt = f"{prompt}\nThe command `{command}` fails: {error}\nWrite a corrected command.\n"
331
+ raise RuntimeError(f"no working command found (last: `{command}`: {error})")
@@ -0,0 +1,104 @@
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ from pathlib import Path
5
+ from typing import Annotated
6
+ from urllib.parse import urlsplit
7
+
8
+ import typer
9
+ from dotenv import load_dotenv
10
+ from rich.console import Console
11
+
12
+ from thetaterm.agent import SHELL, Agent, outside
13
+
14
+ # Per-user config only: a .env in the working directory could be a cloned repo's,
15
+ # and would get to pick the endpoint our API key is sent to.
16
+ CONFIG = (
17
+ Path(os.getenv("XDG_CONFIG_HOME") or Path.home() / ".config") / "thetaterm" / ".env"
18
+ )
19
+ load_dotenv(CONFIG)
20
+
21
+ app = typer.Typer()
22
+ console = Console()
23
+
24
+ DEFAULT_MODEL = "gemma4:e4b"
25
+ DEFAULT_BASE_URL = "http://localhost:11434/v1" # Ollama
26
+ LOCAL_HOSTS = {"localhost", "127.0.0.1", "::1"}
27
+
28
+
29
+ def process_query(agent: Agent, query: str, yes: bool) -> int:
30
+ """Generate a command for the query, confirm it, run it. Returns its exit code."""
31
+ try:
32
+ with console.status("thinking…"):
33
+ command = agent.generate(query)
34
+ except RuntimeError as e:
35
+ console.print(str(e), style="red", markup=False)
36
+ return 1
37
+ except KeyboardInterrupt: # back to the > prompt, no traceback
38
+ return 130
39
+
40
+ # markup=False: `grep [abc] f` must show its brackets, and `[conceal]` mustn't
41
+ # hide words, or the command you approve isn't the one that runs
42
+ console.print(f"$ {command}", style="cyan", markup=False)
43
+ reason = outside(command)
44
+ if reason:
45
+ # never auto-run these, even with -y, and default to no
46
+ console.print(
47
+ f"leaves the current directory: {reason}", style="red", markup=False
48
+ )
49
+ if (reason or not yes) and not typer.confirm("Run it?", default=not reason):
50
+ return 0
51
+ try:
52
+ return subprocess.run(
53
+ command, shell=True, executable=SHELL, check=False
54
+ ).returncode
55
+ except KeyboardInterrupt: # Ctrl-C stops the command, not the session
56
+ return 130
57
+
58
+
59
+ @app.command()
60
+ def tterm(
61
+ query: Annotated[
62
+ str | None,
63
+ typer.Option(
64
+ "--query", "-q", help="The request; without it, start interactive mode"
65
+ ),
66
+ ] = None,
67
+ model: Annotated[
68
+ str, typer.Option("--model", "-m", envvar="THETATERM_MODEL")
69
+ ] = DEFAULT_MODEL,
70
+ yes: Annotated[
71
+ bool, typer.Option("--yes", "-y", help="Run without asking")
72
+ ] = False,
73
+ think: Annotated[
74
+ bool, typer.Option("--think", help="Let the model reason first: slower")
75
+ ] = False,
76
+ ):
77
+ """
78
+ Describe a task in plain words, get a shell command for this system, confirm, run it.
79
+ """
80
+ base_url = os.getenv("THETATERM_BASE_URL") or DEFAULT_BASE_URL
81
+ api_key = os.getenv("THETATERM_API_KEY")
82
+ url = urlsplit(base_url)
83
+ if api_key and url.scheme == "http" and url.hostname not in LOCAL_HOSTS:
84
+ console.print(
85
+ f"[yellow]warning: API key sent unencrypted to {base_url}[/yellow]"
86
+ )
87
+ agent = Agent(model, base_url, api_key, think)
88
+ if query is not None:
89
+ sys.exit(process_query(agent, query, yes))
90
+
91
+ console.print(f"[bold cyan]Thetaterm[/bold cyan] · {model} · {agent.env}")
92
+ console.print("[dim]Describe what you want. Ctrl-D to exit.[/dim]")
93
+ while True:
94
+ try:
95
+ line = input("\n> ").strip()
96
+ except (EOFError, KeyboardInterrupt):
97
+ console.print()
98
+ return
99
+ if line:
100
+ process_query(agent, line, yes)
101
+
102
+
103
+ if __name__ == "__main__":
104
+ app()