forgefy-cli 0.1.0__tar.gz → 0.3.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.
- {forgefy_cli-0.1.0/src/forgefy_cli.egg-info → forgefy_cli-0.3.0}/PKG-INFO +77 -27
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/README.md +76 -26
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/pyproject.toml +1 -1
- forgefy_cli-0.3.0/src/forgefy_cli/auth.py +65 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/chat.py +29 -5
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/cli.py +52 -6
- forgefy_cli-0.3.0/src/forgefy_cli/command_tools.py +92 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/config.py +14 -6
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/editing.py +48 -9
- forgefy_cli-0.3.0/src/forgefy_cli/history.py +63 -0
- forgefy_cli-0.3.0/src/forgefy_cli/login.py +101 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/providers.py +75 -15
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0/src/forgefy_cli.egg-info}/PKG-INFO +77 -27
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/SOURCES.txt +8 -1
- forgefy_cli-0.3.0/tests/test_chat.py +211 -0
- forgefy_cli-0.3.0/tests/test_chat_process.py +213 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/tests/test_cli.py +21 -1
- forgefy_cli-0.3.0/tests/test_command_tools.py +161 -0
- forgefy_cli-0.3.0/tests/test_history.py +66 -0
- forgefy_cli-0.3.0/tests/test_login.py +135 -0
- forgefy_cli-0.1.0/tests/test_chat.py +0 -104
- forgefy_cli-0.1.0/tests/test_chat_process.py +0 -117
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/LICENSE +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/setup.cfg +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/__init__.py +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/context.py +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/file_tools.py +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/skills.py +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/dependency_links.txt +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/entry_points.txt +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/requires.txt +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/top_level.txt +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/tests/test_create.py +0 -0
- {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/tests/test_edit.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: forgefy-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
4
4
|
Summary: Forgefy CLI — coding assistance with local and hosted model-provider profiles.
|
|
5
5
|
License-Expression: LicenseRef-Proprietary
|
|
6
6
|
Project-URL: Homepage, https://forgefy.app
|
|
@@ -27,18 +27,28 @@ coverage depend on the selected model; no model is guaranteed to be best at ever
|
|
|
27
27
|
|
|
28
28
|
## Install
|
|
29
29
|
|
|
30
|
+
No Python required — installs a standalone `forgefy` binary and puts it on
|
|
31
|
+
your PATH, the same way Ollama's or Claude Code's installer does:
|
|
32
|
+
|
|
33
|
+
macOS / Linux:
|
|
34
|
+
```sh
|
|
35
|
+
curl -fsSL https://raw.githubusercontent.com/Polybamz/forgefy-cli/main/install.sh | sh
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Windows (PowerShell):
|
|
30
39
|
```powershell
|
|
31
|
-
|
|
40
|
+
irm https://raw.githubusercontent.com/Polybamz/forgefy-cli/main/install.ps1 | iex
|
|
32
41
|
```
|
|
33
42
|
|
|
34
|
-
|
|
43
|
+
Already have Python? `pip`/`pipx` work too — prefer `pipx` over plain `pip`,
|
|
44
|
+
since `pip install` can silently install to a user directory that isn't on
|
|
45
|
+
your PATH:
|
|
35
46
|
|
|
36
47
|
```powershell
|
|
37
|
-
|
|
48
|
+
pipx install forgefy-cli
|
|
38
49
|
```
|
|
39
50
|
|
|
40
|
-
Either way
|
|
41
|
-
Python environment you ran `pip install` in):
|
|
51
|
+
Either way, open a new terminal and you should have the `forgefy` command:
|
|
42
52
|
|
|
43
53
|
```powershell
|
|
44
54
|
forgefy --help
|
|
@@ -119,14 +129,26 @@ is not a secret scanner: review every file and prompt before sending. Requests h
|
|
|
119
129
|
& 'C:\Users\USER\Desktop\polycarp\.venv\Scripts\forgefy.exe' chat --provider ollama --model llama3:latest
|
|
120
130
|
```
|
|
121
131
|
|
|
122
|
-
Chat retains conversation history
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
132
|
+
Chat retains conversation history for follow-up questions. `/new` clears it, `/help`
|
|
133
|
+
lists commands, and `/exit` or `/quit` ends the session. EOF exits normally; Ctrl+C
|
|
134
|
+
cancels. Each request resends retained history, so hosted-provider usage can grow each
|
|
135
|
+
turn. No automatic paid fallback occurs. Oldest complete user/assistant pairs are
|
|
136
|
+
omitted when conversational content exceeds 120,000 characters; system/skill
|
|
137
|
+
instructions are additional. This is a character cap, not a token budget. Failed
|
|
138
|
+
requests preserve prior history. Chat accepts single-line turns and skill plugins;
|
|
139
|
+
explicit `--file` context is currently supported by `run` only.
|
|
140
|
+
|
|
141
|
+
Replies stream to the terminal as they're generated by default; `--no-stream` waits for
|
|
142
|
+
the complete response instead (both `chat` and `run`).
|
|
143
|
+
|
|
144
|
+
Every session is saved to disk under `--session NAME` (default `"default"`,
|
|
145
|
+
`~/.forgefy/history/NAME.json`, permissioned 0600 where the OS supports it — override
|
|
146
|
+
the directory with `FORGEFY_HISTORY_DIR`). A fresh run always **starts empty**, even
|
|
147
|
+
under a name that already has history — add `--resume` to load that session's prior
|
|
148
|
+
turns first. `--no-history` skips saving entirely, for a fully ephemeral session like
|
|
149
|
+
older versions of this CLI. Session files can contain source code and other workspace
|
|
150
|
+
content pasted into the conversation; they're local-only and never uploaded anywhere by
|
|
151
|
+
this tool.
|
|
130
152
|
|
|
131
153
|
## Approved file editing
|
|
132
154
|
|
|
@@ -159,17 +181,31 @@ means the model finished, not that its changes are correct or tested. Applied ed
|
|
|
159
181
|
remain on disk if the session stops or fails—there is no session-wide rollback.
|
|
160
182
|
Use version control or backups and review the printed list of files actually changed.
|
|
161
183
|
|
|
162
|
-
Editing currently supports replacements
|
|
163
|
-
or custom skill files. Its integration tests use mocked
|
|
164
|
-
files; live model-driven editing has not been verified.
|
|
184
|
+
Editing currently supports replacements and, with `--allow-commands`, running shell
|
|
185
|
+
commands — no file deletion or custom skill files yet. Its integration tests use mocked
|
|
186
|
+
model responses and temporary files; live model-driven editing has not been verified.
|
|
187
|
+
|
|
188
|
+
### Running commands (`--allow-commands`)
|
|
189
|
+
|
|
190
|
+
Off by default. With it, the model gains a `run_command` tool — use it to build, lint,
|
|
191
|
+
or run tests on the files it just edited, closing the loop that used to require you to
|
|
192
|
+
verify changes yourself. The safety model is identical to file edits: the model proposes
|
|
193
|
+
one exact command, you see it and the working directory, and must type `yes` before
|
|
194
|
+
anything runs. **It is not a sandbox** — an approved command runs with your full user
|
|
195
|
+
privileges, filesystem access, and network, exactly as if you'd typed it yourself.
|
|
196
|
+
Approval is the only boundary; review every command before approving it, the same way
|
|
197
|
+
you'd review a diff. `--command-timeout` (default 120s) kills a hung command; stdout and
|
|
198
|
+
stderr are each capped at 32,000 characters before being shown back to the model.
|
|
165
199
|
|
|
166
200
|
## Current boundaries
|
|
167
201
|
|
|
168
|
-
This release can apply approved replacements,
|
|
169
|
-
commands
|
|
170
|
-
to the Forgefy admin catalogue. Provider profiles and
|
|
171
|
-
interfaces, not a full autonomous coding-agent system.
|
|
172
|
-
before running anything
|
|
202
|
+
This release can apply approved replacements and, opt-in, run approved shell commands
|
|
203
|
+
(`--allow-commands`) — but that opt-in is not a sandbox, so read the section above before
|
|
204
|
+
turning it on. It does not connect to the Forgefy admin catalogue. Provider profiles and
|
|
205
|
+
skill files are the initial plugin interfaces, not a full autonomous coding-agent system.
|
|
206
|
+
Output is untrusted: inspect it before running anything, whether it's a file diff or a
|
|
207
|
+
command result. Tests use mocked HTTP (and, for the process-level suite, a real loopback
|
|
208
|
+
server), not live model quality benchmarks.
|
|
173
209
|
|
|
174
210
|
## Tests
|
|
175
211
|
|
|
@@ -179,12 +215,26 @@ pytest
|
|
|
179
215
|
|
|
180
216
|
## Releasing (maintainers)
|
|
181
217
|
|
|
182
|
-
CI runs on every push/PR (`.github/workflows/ci.yml`).
|
|
218
|
+
CI runs on every push/PR (`.github/workflows/ci.yml`). One tag produces both
|
|
219
|
+
distribution channels:
|
|
183
220
|
|
|
184
221
|
1. Bump `version` in `pyproject.toml` and commit.
|
|
185
222
|
2. `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
|
186
|
-
3. `.github/workflows/release.yml`
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
223
|
+
3. `.github/workflows/release.yml` then, in parallel:
|
|
224
|
+
- builds and publishes the PyPI package via Trusted Publishing — no token
|
|
225
|
+
stored in the repo. One-time setup: on the PyPI project's *Publishing*
|
|
226
|
+
settings, add a Trusted Publisher for `Polybamz/forgefy-cli`, workflow
|
|
227
|
+
`release.yml`, environment `pypi`.
|
|
228
|
+
- builds a standalone `forgefy` binary for Windows/macOS/Linux with
|
|
229
|
+
PyInstaller and attaches them to a GitHub Release for the tag — what
|
|
230
|
+
`install.sh`/`install.ps1` fetch. No setup needed; uses the repo's
|
|
231
|
+
built-in `GITHUB_TOKEN`.
|
|
232
|
+
|
|
233
|
+
To build the standalone binary locally (e.g. to test before tagging):
|
|
234
|
+
|
|
235
|
+
```powershell
|
|
236
|
+
pip install -e . pyinstaller
|
|
237
|
+
pyinstaller --onefile --name forgefy --paths src --hidden-import anyio._backends._asyncio --distpath dist_native --workpath build_native --specpath build_native build_installer/entrypoint.py
|
|
238
|
+
.\dist_native\forgefy.exe --help
|
|
239
|
+
```
|
|
190
240
|
|
|
@@ -6,18 +6,28 @@ coverage depend on the selected model; no model is guaranteed to be best at ever
|
|
|
6
6
|
|
|
7
7
|
## Install
|
|
8
8
|
|
|
9
|
+
No Python required — installs a standalone `forgefy` binary and puts it on
|
|
10
|
+
your PATH, the same way Ollama's or Claude Code's installer does:
|
|
11
|
+
|
|
12
|
+
macOS / Linux:
|
|
13
|
+
```sh
|
|
14
|
+
curl -fsSL https://raw.githubusercontent.com/Polybamz/forgefy-cli/main/install.sh | sh
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Windows (PowerShell):
|
|
9
18
|
```powershell
|
|
10
|
-
|
|
19
|
+
irm https://raw.githubusercontent.com/Polybamz/forgefy-cli/main/install.ps1 | iex
|
|
11
20
|
```
|
|
12
21
|
|
|
13
|
-
|
|
22
|
+
Already have Python? `pip`/`pipx` work too — prefer `pipx` over plain `pip`,
|
|
23
|
+
since `pip install` can silently install to a user directory that isn't on
|
|
24
|
+
your PATH:
|
|
14
25
|
|
|
15
26
|
```powershell
|
|
16
|
-
|
|
27
|
+
pipx install forgefy-cli
|
|
17
28
|
```
|
|
18
29
|
|
|
19
|
-
Either way
|
|
20
|
-
Python environment you ran `pip install` in):
|
|
30
|
+
Either way, open a new terminal and you should have the `forgefy` command:
|
|
21
31
|
|
|
22
32
|
```powershell
|
|
23
33
|
forgefy --help
|
|
@@ -98,14 +108,26 @@ is not a secret scanner: review every file and prompt before sending. Requests h
|
|
|
98
108
|
& 'C:\Users\USER\Desktop\polycarp\.venv\Scripts\forgefy.exe' chat --provider ollama --model llama3:latest
|
|
99
109
|
```
|
|
100
110
|
|
|
101
|
-
Chat retains conversation history
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
111
|
+
Chat retains conversation history for follow-up questions. `/new` clears it, `/help`
|
|
112
|
+
lists commands, and `/exit` or `/quit` ends the session. EOF exits normally; Ctrl+C
|
|
113
|
+
cancels. Each request resends retained history, so hosted-provider usage can grow each
|
|
114
|
+
turn. No automatic paid fallback occurs. Oldest complete user/assistant pairs are
|
|
115
|
+
omitted when conversational content exceeds 120,000 characters; system/skill
|
|
116
|
+
instructions are additional. This is a character cap, not a token budget. Failed
|
|
117
|
+
requests preserve prior history. Chat accepts single-line turns and skill plugins;
|
|
118
|
+
explicit `--file` context is currently supported by `run` only.
|
|
119
|
+
|
|
120
|
+
Replies stream to the terminal as they're generated by default; `--no-stream` waits for
|
|
121
|
+
the complete response instead (both `chat` and `run`).
|
|
122
|
+
|
|
123
|
+
Every session is saved to disk under `--session NAME` (default `"default"`,
|
|
124
|
+
`~/.forgefy/history/NAME.json`, permissioned 0600 where the OS supports it — override
|
|
125
|
+
the directory with `FORGEFY_HISTORY_DIR`). A fresh run always **starts empty**, even
|
|
126
|
+
under a name that already has history — add `--resume` to load that session's prior
|
|
127
|
+
turns first. `--no-history` skips saving entirely, for a fully ephemeral session like
|
|
128
|
+
older versions of this CLI. Session files can contain source code and other workspace
|
|
129
|
+
content pasted into the conversation; they're local-only and never uploaded anywhere by
|
|
130
|
+
this tool.
|
|
109
131
|
|
|
110
132
|
## Approved file editing
|
|
111
133
|
|
|
@@ -138,17 +160,31 @@ means the model finished, not that its changes are correct or tested. Applied ed
|
|
|
138
160
|
remain on disk if the session stops or fails—there is no session-wide rollback.
|
|
139
161
|
Use version control or backups and review the printed list of files actually changed.
|
|
140
162
|
|
|
141
|
-
Editing currently supports replacements
|
|
142
|
-
or custom skill files. Its integration tests use mocked
|
|
143
|
-
files; live model-driven editing has not been verified.
|
|
163
|
+
Editing currently supports replacements and, with `--allow-commands`, running shell
|
|
164
|
+
commands — no file deletion or custom skill files yet. Its integration tests use mocked
|
|
165
|
+
model responses and temporary files; live model-driven editing has not been verified.
|
|
166
|
+
|
|
167
|
+
### Running commands (`--allow-commands`)
|
|
168
|
+
|
|
169
|
+
Off by default. With it, the model gains a `run_command` tool — use it to build, lint,
|
|
170
|
+
or run tests on the files it just edited, closing the loop that used to require you to
|
|
171
|
+
verify changes yourself. The safety model is identical to file edits: the model proposes
|
|
172
|
+
one exact command, you see it and the working directory, and must type `yes` before
|
|
173
|
+
anything runs. **It is not a sandbox** — an approved command runs with your full user
|
|
174
|
+
privileges, filesystem access, and network, exactly as if you'd typed it yourself.
|
|
175
|
+
Approval is the only boundary; review every command before approving it, the same way
|
|
176
|
+
you'd review a diff. `--command-timeout` (default 120s) kills a hung command; stdout and
|
|
177
|
+
stderr are each capped at 32,000 characters before being shown back to the model.
|
|
144
178
|
|
|
145
179
|
## Current boundaries
|
|
146
180
|
|
|
147
|
-
This release can apply approved replacements,
|
|
148
|
-
commands
|
|
149
|
-
to the Forgefy admin catalogue. Provider profiles and
|
|
150
|
-
interfaces, not a full autonomous coding-agent system.
|
|
151
|
-
before running anything
|
|
181
|
+
This release can apply approved replacements and, opt-in, run approved shell commands
|
|
182
|
+
(`--allow-commands`) — but that opt-in is not a sandbox, so read the section above before
|
|
183
|
+
turning it on. It does not connect to the Forgefy admin catalogue. Provider profiles and
|
|
184
|
+
skill files are the initial plugin interfaces, not a full autonomous coding-agent system.
|
|
185
|
+
Output is untrusted: inspect it before running anything, whether it's a file diff or a
|
|
186
|
+
command result. Tests use mocked HTTP (and, for the process-level suite, a real loopback
|
|
187
|
+
server), not live model quality benchmarks.
|
|
152
188
|
|
|
153
189
|
## Tests
|
|
154
190
|
|
|
@@ -158,12 +194,26 @@ pytest
|
|
|
158
194
|
|
|
159
195
|
## Releasing (maintainers)
|
|
160
196
|
|
|
161
|
-
CI runs on every push/PR (`.github/workflows/ci.yml`).
|
|
197
|
+
CI runs on every push/PR (`.github/workflows/ci.yml`). One tag produces both
|
|
198
|
+
distribution channels:
|
|
162
199
|
|
|
163
200
|
1. Bump `version` in `pyproject.toml` and commit.
|
|
164
201
|
2. `git tag vX.Y.Z && git push origin vX.Y.Z`.
|
|
165
|
-
3. `.github/workflows/release.yml`
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
202
|
+
3. `.github/workflows/release.yml` then, in parallel:
|
|
203
|
+
- builds and publishes the PyPI package via Trusted Publishing — no token
|
|
204
|
+
stored in the repo. One-time setup: on the PyPI project's *Publishing*
|
|
205
|
+
settings, add a Trusted Publisher for `Polybamz/forgefy-cli`, workflow
|
|
206
|
+
`release.yml`, environment `pypi`.
|
|
207
|
+
- builds a standalone `forgefy` binary for Windows/macOS/Linux with
|
|
208
|
+
PyInstaller and attaches them to a GitHub Release for the tag — what
|
|
209
|
+
`install.sh`/`install.ps1` fetch. No setup needed; uses the repo's
|
|
210
|
+
built-in `GITHUB_TOKEN`.
|
|
211
|
+
|
|
212
|
+
To build the standalone binary locally (e.g. to test before tagging):
|
|
213
|
+
|
|
214
|
+
```powershell
|
|
215
|
+
pip install -e . pyinstaller
|
|
216
|
+
pyinstaller --onefile --name forgefy --paths src --hidden-import anyio._backends._asyncio --distpath dist_native --workpath build_native --specpath build_native build_installer/entrypoint.py
|
|
217
|
+
.\dist_native\forgefy.exe --help
|
|
218
|
+
```
|
|
169
219
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""Local storage for the credential `forgefy login` obtains.
|
|
2
|
+
|
|
3
|
+
config.py is explicit that provider credentials are read only from the
|
|
4
|
+
environment and never stored in config.toml. This module doesn't relax that:
|
|
5
|
+
Provider.headers() still reads only os.environ. `forgefy login` just gives
|
|
6
|
+
one more, opt-in way to populate $FORGEFY_API_KEY for the process — from a
|
|
7
|
+
single-purpose file the user created by explicitly running `login`, kept out
|
|
8
|
+
of config.toml and permissioned as tightly as the OS allows, the same
|
|
9
|
+
convention `gh`/`docker`/`npm` use for their own login credential.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import stat
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def credentials_path() -> Path:
|
|
20
|
+
return Path(
|
|
21
|
+
os.environ.get("FORGEFY_CREDENTIALS", str(Path.home() / ".forgefy" / "credentials.json"))
|
|
22
|
+
).expanduser()
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def save_credentials(api_key: str) -> Path:
|
|
26
|
+
path = credentials_path()
|
|
27
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
28
|
+
path.write_text(json.dumps({"api_key": api_key}), encoding="utf-8")
|
|
29
|
+
try:
|
|
30
|
+
os.chmod(path, stat.S_IRUSR | stat.S_IWUSR) # 0600 — no-op on Windows, effective on POSIX
|
|
31
|
+
except OSError:
|
|
32
|
+
pass # best-effort; some filesystems (e.g. certain network mounts) reject chmod
|
|
33
|
+
return path
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def load_credentials() -> str | None:
|
|
37
|
+
path = credentials_path()
|
|
38
|
+
if not path.exists():
|
|
39
|
+
return None
|
|
40
|
+
try:
|
|
41
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
42
|
+
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
|
43
|
+
return None
|
|
44
|
+
key = data.get("api_key") if isinstance(data, dict) else None
|
|
45
|
+
return key if isinstance(key, str) and key else None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def clear_credentials() -> bool:
|
|
49
|
+
path = credentials_path()
|
|
50
|
+
if path.exists():
|
|
51
|
+
path.unlink()
|
|
52
|
+
return True
|
|
53
|
+
return False
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def bootstrap_env() -> None:
|
|
57
|
+
"""Populate $FORGEFY_API_KEY from the stored credential if not already set.
|
|
58
|
+
|
|
59
|
+
Called once at CLI startup, before providers are resolved. An explicitly
|
|
60
|
+
exported FORGEFY_API_KEY always wins over a stored one.
|
|
61
|
+
"""
|
|
62
|
+
if not os.environ.get("FORGEFY_API_KEY"):
|
|
63
|
+
key = load_credentials()
|
|
64
|
+
if key:
|
|
65
|
+
os.environ["FORGEFY_API_KEY"] = key
|
|
@@ -9,9 +9,28 @@ from .providers import ProviderError
|
|
|
9
9
|
LEAVING = "/exit or /quit (leave), /new (clear history), /help (commands)"
|
|
10
10
|
|
|
11
11
|
|
|
12
|
-
def
|
|
13
|
-
"""
|
|
14
|
-
|
|
12
|
+
def print_fragment(text: str) -> None:
|
|
13
|
+
"""Default on_token: write one streamed piece with no newline, flushed immediately."""
|
|
14
|
+
print(text, end="", flush=True)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def chat_loop(client, model: str, system: str, input_fn: Callable[..., str] = input, output: Callable[[str], None] = print,
|
|
18
|
+
on_token: Callable[[str], None] | None = print_fragment,
|
|
19
|
+
history: list[tuple[str, str]] | None = None,
|
|
20
|
+
on_turn: Callable[[list[tuple[str, str]]], None] = lambda h: None) -> int:
|
|
21
|
+
"""Read user turns until /exit, /quit or end of input. History is sent each turn.
|
|
22
|
+
|
|
23
|
+
`on_token`, when not None, streams the reply to it fragment-by-fragment as
|
|
24
|
+
it's generated (default: print live); the accumulated reply is then NOT
|
|
25
|
+
also passed to `output`, to avoid printing it twice — pass on_token=None
|
|
26
|
+
for the old blocking behavior, where `output(reply)` prints it once, whole.
|
|
27
|
+
|
|
28
|
+
`history` seeds a resumed session (see forgefy_cli.history); `on_turn` is
|
|
29
|
+
called with the updated history after every completed exchange and after
|
|
30
|
+
/new, so a caller can persist it incrementally — a crash mid-session loses
|
|
31
|
+
at most the in-flight turn, not the whole conversation.
|
|
32
|
+
"""
|
|
33
|
+
history = list(history or [])
|
|
15
34
|
while True:
|
|
16
35
|
try:
|
|
17
36
|
line = input_fn("you> ").strip()
|
|
@@ -23,6 +42,7 @@ def chat_loop(client, model: str, system: str, input_fn: Callable[..., str] = in
|
|
|
23
42
|
return 0
|
|
24
43
|
if line == "/new":
|
|
25
44
|
history.clear()
|
|
45
|
+
on_turn(history)
|
|
26
46
|
output("Context cleared.")
|
|
27
47
|
continue
|
|
28
48
|
if line == "/help":
|
|
@@ -39,11 +59,15 @@ def chat_loop(client, model: str, system: str, input_fn: Callable[..., str] = in
|
|
|
39
59
|
del candidate[:2] # Always discard a complete user/assistant pair.
|
|
40
60
|
removed += 1
|
|
41
61
|
try:
|
|
42
|
-
reply = client.chat(model, system, candidate)
|
|
62
|
+
reply = client.chat(model, system, candidate, on_token=on_token)
|
|
43
63
|
except (ProviderError, ValueError) as exc:
|
|
44
64
|
output(f"Forgefy: {exc}")
|
|
45
65
|
continue
|
|
46
66
|
history = candidate + [("assistant", reply)]
|
|
67
|
+
on_turn(history)
|
|
47
68
|
if removed:
|
|
48
69
|
output(f"Context limit: omitted {removed} oldest turn pair(s).")
|
|
49
|
-
|
|
70
|
+
if on_token is None:
|
|
71
|
+
output(reply)
|
|
72
|
+
else:
|
|
73
|
+
output("") # the reply was already streamed; just close the line
|
|
@@ -2,23 +2,31 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
|
|
4
4
|
import argparse
|
|
5
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
5
6
|
import os
|
|
6
7
|
from pathlib import Path
|
|
7
8
|
import sys
|
|
8
9
|
|
|
9
10
|
import httpx
|
|
10
11
|
|
|
11
|
-
from .
|
|
12
|
+
from .auth import bootstrap_env
|
|
13
|
+
from .chat import chat_loop, print_fragment
|
|
12
14
|
from .config import TEMPLATE, config_path, load_config
|
|
13
15
|
from .context import build_prompt
|
|
14
16
|
from .editing import edit_files
|
|
17
|
+
from .history import load_session, save_session, session_path
|
|
15
18
|
from .providers import ModelClient, ProviderError
|
|
16
19
|
from .skills import SKILLS, system_prompt
|
|
17
20
|
|
|
21
|
+
try:
|
|
22
|
+
_VERSION = version("forgefy-cli")
|
|
23
|
+
except PackageNotFoundError: # running from source without an install record
|
|
24
|
+
_VERSION = "0.0.0-dev"
|
|
25
|
+
|
|
18
26
|
|
|
19
27
|
def parser() -> argparse.ArgumentParser:
|
|
20
|
-
result = argparse.ArgumentParser(prog="forgefy", description="Forgefy: local or hosted coding assistance. Run/chat suggest; edit applies approved file changes
|
|
21
|
-
result.add_argument("--version", action="version", version="Forgefy CLI
|
|
28
|
+
result = argparse.ArgumentParser(prog="forgefy", description="Forgefy: local or hosted coding assistance. Run/chat suggest; edit applies approved file changes and, with --allow-commands, runs approved shell commands.")
|
|
29
|
+
result.add_argument("--version", action="version", version=f"Forgefy CLI {_VERSION}")
|
|
22
30
|
sub = result.add_subparsers(dest="command", required=True)
|
|
23
31
|
run = sub.add_parser("run", help="Send one coding request and print the response")
|
|
24
32
|
run.add_argument("prompt", help="Coding request; use '-' to read from stdin")
|
|
@@ -28,11 +36,16 @@ def parser() -> argparse.ArgumentParser:
|
|
|
28
36
|
run.add_argument("--file", action="append", default=[], help="Explicit relative file to send; repeatable. Review for secrets first.")
|
|
29
37
|
run.add_argument("--skill", choices=sorted(SKILLS), default="code")
|
|
30
38
|
run.add_argument("--skill-file", type=Path, action="append", default=[], help="Trusted Markdown instructions to send; repeatable")
|
|
39
|
+
run.add_argument("--no-stream", action="store_true", help="Wait for the full response instead of printing it as it streams")
|
|
31
40
|
chat = sub.add_parser("chat", help="Multi-turn conversation; replies are suggestions to review")
|
|
32
41
|
chat.add_argument("--provider", help="Provider profile name (default: config or ollama)")
|
|
33
42
|
chat.add_argument("--model", help="Exact provider model ID; no automatic paid fallback")
|
|
34
43
|
chat.add_argument("--skill", choices=sorted(SKILLS), default="code")
|
|
35
44
|
chat.add_argument("--skill-file", type=Path, action="append", default=[], help="Trusted Markdown instructions to send; repeatable")
|
|
45
|
+
chat.add_argument("--no-stream", action="store_true", help="Wait for each full response instead of printing it as it streams")
|
|
46
|
+
chat.add_argument("--session", default="default", help="Named session to save to disk (default: 'default')")
|
|
47
|
+
chat.add_argument("--resume", action="store_true", help="Load previous turns from --session before starting; without this, every run starts fresh (but is still saved)")
|
|
48
|
+
chat.add_argument("--no-history", action="store_true", help="Don't load or save this session at all; ephemeral like before")
|
|
36
49
|
edit = sub.add_parser("edit", help="Edit explicitly selected existing files with approval for every diff")
|
|
37
50
|
edit.add_argument("prompt", help="Requested change")
|
|
38
51
|
edit.add_argument("--provider", help="Provider profile; requires a tool-calling model")
|
|
@@ -41,8 +54,14 @@ def parser() -> argparse.ArgumentParser:
|
|
|
41
54
|
edit.add_argument("--file", action="append", default=[], help="Allowed existing relative file; repeatable")
|
|
42
55
|
edit.add_argument("--create", action="append", default=[], help="Approved relative path to create; repeatable, parent dir must exist")
|
|
43
56
|
edit.add_argument("--max-turns", type=int, default=12, help="Maximum model requests (1–30; default 12)")
|
|
57
|
+
edit.add_argument("--allow-commands", action="store_true",
|
|
58
|
+
help="Let the model propose shell commands (e.g. to run tests); each still requires your approval. Not sandboxed.")
|
|
59
|
+
edit.add_argument("--command-timeout", type=int, default=120, help="Seconds before an approved command is killed (default 120)")
|
|
44
60
|
models = sub.add_parser("models", help="List live provider model IDs (availability and pricing vary)")
|
|
45
61
|
models.add_argument("--provider")
|
|
62
|
+
login = sub.add_parser("login", help="Sign in with your Forgefy account via the browser (device code)")
|
|
63
|
+
login.add_argument("--api-url", help="Override FORGEFY_API_URL for this login only")
|
|
64
|
+
sub.add_parser("logout", help="Remove the locally stored Forgefy account credential")
|
|
46
65
|
sub.add_parser("providers", help="List built-in and configured provider plugins")
|
|
47
66
|
sub.add_parser("skills", help="List built-in coding skills")
|
|
48
67
|
sub.add_parser("doctor", help="Check configuration and key presence without sending requests")
|
|
@@ -53,7 +72,15 @@ def parser() -> argparse.ArgumentParser:
|
|
|
53
72
|
|
|
54
73
|
def main(argv: list[str] | None = None) -> int:
|
|
55
74
|
args = parser().parse_args(argv)
|
|
75
|
+
bootstrap_env()
|
|
56
76
|
try:
|
|
77
|
+
if args.command == "login":
|
|
78
|
+
from .login import device_login
|
|
79
|
+
with httpx.Client(timeout=15) as http:
|
|
80
|
+
return device_login(http, api_url=args.api_url)
|
|
81
|
+
if args.command == "logout":
|
|
82
|
+
from .login import logout
|
|
83
|
+
return logout()
|
|
57
84
|
if args.command == "config":
|
|
58
85
|
path = config_path()
|
|
59
86
|
if args.init:
|
|
@@ -90,7 +117,8 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
90
117
|
with httpx.Client(timeout=httpx.Timeout(120, connect=10)) as http:
|
|
91
118
|
client = ModelClient(provider, http)
|
|
92
119
|
if args.command == "edit":
|
|
93
|
-
|
|
120
|
+
return edit_files(client, model, args.prompt, args.workspace, args.file, args.max_turns,
|
|
121
|
+
args.create, args.allow_commands, args.command_timeout)
|
|
94
122
|
if args.command == "models":
|
|
95
123
|
for model_id in client.models():
|
|
96
124
|
print(model_id)
|
|
@@ -98,10 +126,28 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
98
126
|
prompt = sys.stdin.read(120001) if args.prompt == "-" else args.prompt
|
|
99
127
|
prompt = build_prompt(prompt, args.workspace, args.file)
|
|
100
128
|
print(f"Sending request to {name} / {model}. Provider pricing applies; no fallback.", file=sys.stderr)
|
|
101
|
-
|
|
129
|
+
if args.no_stream:
|
|
130
|
+
print(client.complete(model, system, prompt))
|
|
131
|
+
else:
|
|
132
|
+
client.complete(model, system, prompt, on_token=print_fragment)
|
|
133
|
+
print()
|
|
102
134
|
else:
|
|
103
135
|
print(f"Chatting with {name} / {model}. /exit to leave; replies are suggestions to review, never executed.", file=sys.stderr)
|
|
104
|
-
|
|
136
|
+
if args.no_history:
|
|
137
|
+
initial_history, on_turn = [], (lambda h: None)
|
|
138
|
+
else:
|
|
139
|
+
initial_history = load_session(args.session) if args.resume else []
|
|
140
|
+
on_turn = lambda h: save_session(args.session, h) # noqa: E731
|
|
141
|
+
if args.resume and initial_history:
|
|
142
|
+
print(f"Resumed session '{args.session}' ({len(initial_history) // 2} previous turn(s)). "
|
|
143
|
+
f"/new to start fresh.", file=sys.stderr)
|
|
144
|
+
elif args.resume:
|
|
145
|
+
print(f"No previous history for session '{args.session}' — starting fresh.", file=sys.stderr)
|
|
146
|
+
else:
|
|
147
|
+
print(f"Session '{args.session}' will be saved to {session_path(args.session)}. "
|
|
148
|
+
f"Use --resume to continue it next time.", file=sys.stderr)
|
|
149
|
+
chat_on_token = None if args.no_stream else print_fragment
|
|
150
|
+
chat_loop(client, model, system, on_token=chat_on_token, history=initial_history, on_turn=on_turn)
|
|
105
151
|
return 0
|
|
106
152
|
except (ValueError, OSError, ProviderError) as exc:
|
|
107
153
|
print(f"Forgefy: {exc}", file=sys.stderr)
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Explicit, approval-gated shell command execution.
|
|
2
|
+
|
|
3
|
+
Opt-in only: `forgefy edit --allow-commands`. Kept in its own module, separate
|
|
4
|
+
from file_tools.py (whose docstring promises no shell execution) — that
|
|
5
|
+
promise stays true by default; this is what a caller turns on deliberately.
|
|
6
|
+
|
|
7
|
+
Not a sandbox. A command approved here runs with the user's full OS
|
|
8
|
+
privileges, filesystem access, and network — the only safety boundary is the
|
|
9
|
+
same one file edits already use: the model proposes an exact command, the
|
|
10
|
+
user sees it and must approve it before anything runs.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import subprocess
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Callable
|
|
18
|
+
|
|
19
|
+
# Matches file_tools.FILE_LIMIT — keeps a tool result within the CLI's
|
|
20
|
+
# character-budget context cap regardless of how chatty a command's output is.
|
|
21
|
+
OUTPUT_LIMIT = 32000
|
|
22
|
+
DEFAULT_TIMEOUT = 120
|
|
23
|
+
|
|
24
|
+
RUN_COMMAND_TOOL = {
|
|
25
|
+
"type": "function",
|
|
26
|
+
"function": {
|
|
27
|
+
"name": "run_command",
|
|
28
|
+
"description": (
|
|
29
|
+
"Run one exact shell command line in the workspace root, after user approval. "
|
|
30
|
+
"Use to build, lint, or run tests on files you've edited. Runs with the user's "
|
|
31
|
+
"full privileges and network access — it is not sandboxed."
|
|
32
|
+
),
|
|
33
|
+
"parameters": {
|
|
34
|
+
"type": "object",
|
|
35
|
+
"properties": {"command": {"type": "string"}},
|
|
36
|
+
"required": ["command"],
|
|
37
|
+
"additionalProperties": False,
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _truncate(text: str) -> str:
|
|
44
|
+
if len(text) <= OUTPUT_LIMIT:
|
|
45
|
+
return text
|
|
46
|
+
omitted = len(text) - OUTPUT_LIMIT
|
|
47
|
+
return text[:OUTPUT_LIMIT] + f"\n...[truncated, {omitted} more characters]"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class CommandRunner:
|
|
51
|
+
def __init__(self, workspace: Path, approve: Callable[[str], bool], timeout: int = DEFAULT_TIMEOUT) -> None:
|
|
52
|
+
self.root = workspace
|
|
53
|
+
self.approve = approve
|
|
54
|
+
self.timeout = timeout
|
|
55
|
+
self.ran: list[str] = []
|
|
56
|
+
|
|
57
|
+
def execute(self, arguments: str) -> str:
|
|
58
|
+
try:
|
|
59
|
+
args = json.loads(arguments)
|
|
60
|
+
if not isinstance(args, dict) or set(args) != {"command"} or not isinstance(args["command"], str):
|
|
61
|
+
raise ValueError("Tool arguments do not match the schema.")
|
|
62
|
+
command = args["command"].strip()
|
|
63
|
+
if not command:
|
|
64
|
+
raise ValueError("command must be nonempty.")
|
|
65
|
+
except (ValueError, TypeError) as exc:
|
|
66
|
+
return json.dumps({"error": str(exc)})
|
|
67
|
+
|
|
68
|
+
if not self.approve(f"$ {command}\n(cwd: {self.root})"):
|
|
69
|
+
return json.dumps({"error": "The user declined to run this command; it did not run."})
|
|
70
|
+
|
|
71
|
+
try:
|
|
72
|
+
result = subprocess.run(
|
|
73
|
+
command,
|
|
74
|
+
shell=True, # the command is one already-approved string, not argv — approval IS the trust boundary
|
|
75
|
+
cwd=self.root,
|
|
76
|
+
capture_output=True,
|
|
77
|
+
timeout=self.timeout,
|
|
78
|
+
text=True,
|
|
79
|
+
errors="replace",
|
|
80
|
+
check=False, # exit_code is reported back to the model, never raised
|
|
81
|
+
)
|
|
82
|
+
except subprocess.TimeoutExpired:
|
|
83
|
+
return json.dumps({"error": f"Command timed out after {self.timeout}s."})
|
|
84
|
+
except OSError as exc:
|
|
85
|
+
return json.dumps({"error": f"Could not run command: {exc}"})
|
|
86
|
+
|
|
87
|
+
self.ran.append(command)
|
|
88
|
+
return json.dumps({
|
|
89
|
+
"exit_code": result.returncode,
|
|
90
|
+
"stdout": _truncate(result.stdout),
|
|
91
|
+
"stderr": _truncate(result.stderr),
|
|
92
|
+
})
|