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.
Files changed (34) hide show
  1. {forgefy_cli-0.1.0/src/forgefy_cli.egg-info → forgefy_cli-0.3.0}/PKG-INFO +77 -27
  2. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/README.md +76 -26
  3. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/pyproject.toml +1 -1
  4. forgefy_cli-0.3.0/src/forgefy_cli/auth.py +65 -0
  5. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/chat.py +29 -5
  6. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/cli.py +52 -6
  7. forgefy_cli-0.3.0/src/forgefy_cli/command_tools.py +92 -0
  8. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/config.py +14 -6
  9. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/editing.py +48 -9
  10. forgefy_cli-0.3.0/src/forgefy_cli/history.py +63 -0
  11. forgefy_cli-0.3.0/src/forgefy_cli/login.py +101 -0
  12. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/providers.py +75 -15
  13. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0/src/forgefy_cli.egg-info}/PKG-INFO +77 -27
  14. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/SOURCES.txt +8 -1
  15. forgefy_cli-0.3.0/tests/test_chat.py +211 -0
  16. forgefy_cli-0.3.0/tests/test_chat_process.py +213 -0
  17. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/tests/test_cli.py +21 -1
  18. forgefy_cli-0.3.0/tests/test_command_tools.py +161 -0
  19. forgefy_cli-0.3.0/tests/test_history.py +66 -0
  20. forgefy_cli-0.3.0/tests/test_login.py +135 -0
  21. forgefy_cli-0.1.0/tests/test_chat.py +0 -104
  22. forgefy_cli-0.1.0/tests/test_chat_process.py +0 -117
  23. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/LICENSE +0 -0
  24. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/setup.cfg +0 -0
  25. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/__init__.py +0 -0
  26. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/context.py +0 -0
  27. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/file_tools.py +0 -0
  28. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli/skills.py +0 -0
  29. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/dependency_links.txt +0 -0
  30. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/entry_points.txt +0 -0
  31. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/requires.txt +0 -0
  32. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/src/forgefy_cli.egg-info/top_level.txt +0 -0
  33. {forgefy_cli-0.1.0 → forgefy_cli-0.3.0}/tests/test_create.py +0 -0
  34. {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.1.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
- pip install forgefy-cli
40
+ irm https://raw.githubusercontent.com/Polybamz/forgefy-cli/main/install.ps1 | iex
32
41
  ```
33
42
 
34
- Not yet published to PyPI? Install straight from GitHub instead:
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
- pip install git+https://github.com/Polybamz/forgefy-cli.git
48
+ pipx install forgefy-cli
38
49
  ```
39
50
 
40
- Either way this creates the `forgefy` executable on your PATH (inside whichever
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 in memory for follow-up questions. `/new` clears it,
123
- `/help` lists commands, and `/exit` or `/quit` ends the session. EOF exits normally;
124
- Ctrl+C cancels. History is not saved to disk. Each request resends retained history,
125
- so hosted-provider usage can grow each turn. No automatic paid fallback occurs.
126
- Oldest complete user/assistant pairs are omitted when conversational content exceeds
127
- 120,000 characters; system/skill instructions are additional. This is a character cap,
128
- not a token budget. Failed requests preserve prior history. Chat accepts single-line
129
- turns and skill plugins; explicit `--file` context is currently supported by `run` only.
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 only: no file creation, deletion, shell commands,
163
- or custom skill files. Its integration tests use mocked model responses and temporary
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, but it does **not** execute
169
- commands, run tests on generated code, stream tokens, persist chat history, or connect
170
- to the Forgefy admin catalogue. Provider profiles and skill files are the initial plugin
171
- interfaces, not a full autonomous coding-agent system. Output is untrusted: inspect it
172
- before running anything. Tests use mocked HTTP, not live model quality benchmarks.
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`). To publish a new version to PyPI:
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` builds, tests, and publishes via PyPI Trusted
187
- Publishing — no token stored in the repo. One-time setup: on the PyPI project's
188
- *Publishing* settings, add a Trusted Publisher for `Polybamz/forgefy-cli`,
189
- workflow `release.yml`, environment `pypi`.
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
- pip install forgefy-cli
19
+ irm https://raw.githubusercontent.com/Polybamz/forgefy-cli/main/install.ps1 | iex
11
20
  ```
12
21
 
13
- Not yet published to PyPI? Install straight from GitHub instead:
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
- pip install git+https://github.com/Polybamz/forgefy-cli.git
27
+ pipx install forgefy-cli
17
28
  ```
18
29
 
19
- Either way this creates the `forgefy` executable on your PATH (inside whichever
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 in memory for follow-up questions. `/new` clears it,
102
- `/help` lists commands, and `/exit` or `/quit` ends the session. EOF exits normally;
103
- Ctrl+C cancels. History is not saved to disk. Each request resends retained history,
104
- so hosted-provider usage can grow each turn. No automatic paid fallback occurs.
105
- Oldest complete user/assistant pairs are omitted when conversational content exceeds
106
- 120,000 characters; system/skill instructions are additional. This is a character cap,
107
- not a token budget. Failed requests preserve prior history. Chat accepts single-line
108
- turns and skill plugins; explicit `--file` context is currently supported by `run` only.
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 only: no file creation, deletion, shell commands,
142
- or custom skill files. Its integration tests use mocked model responses and temporary
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, but it does **not** execute
148
- commands, run tests on generated code, stream tokens, persist chat history, or connect
149
- to the Forgefy admin catalogue. Provider profiles and skill files are the initial plugin
150
- interfaces, not a full autonomous coding-agent system. Output is untrusted: inspect it
151
- before running anything. Tests use mocked HTTP, not live model quality benchmarks.
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`). To publish a new version to PyPI:
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` builds, tests, and publishes via PyPI Trusted
166
- Publishing — no token stored in the repo. One-time setup: on the PyPI project's
167
- *Publishing* settings, add a Trusted Publisher for `Polybamz/forgefy-cli`,
168
- workflow `release.yml`, environment `pypi`.
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
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "forgefy-cli"
3
- version = "0.1.0"
3
+ version = "0.3.0"
4
4
  description = "Forgefy CLI — coding assistance with local and hosted model-provider profiles."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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 chat_loop(client, model: str, system: str, input_fn: Callable[..., str] = input, output: Callable[[str], None] = print) -> int:
13
- """Read user turns until /exit, /quit or end of input. History is sent each turn."""
14
- history: list[tuple[str, str]] = []
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
- output(reply)
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 .chat import chat_loop
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. No command execution.")
21
- result.add_argument("--version", action="version", version="Forgefy CLI 0.1.0")
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
- return edit_files(client, model, args.prompt, args.workspace, args.file, args.max_turns, args.create)
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
- print(client.complete(model, system, prompt))
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
- chat_loop(client, model, system)
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
+ })