forgegrit-cli 1.0.0__py3-none-any.whl

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 @@
1
+ __version__ = "1.0.0"
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ main()
forgegrit_cli/main.py ADDED
@@ -0,0 +1,408 @@
1
+ """ForgeGrit CLI — talk to the ForgeGrit API from a terminal.
2
+
3
+ Auth (pick one):
4
+ forgegrit login log in with your ForgeGrit email + password
5
+ forgegrit register create a new ForgeGrit account
6
+ forgegrit api KEY use an API key you already have
7
+
8
+ Then:
9
+ forgegrit chat interactive chat (streams replies)
10
+ forgegrit ask "..." one question, one answer
11
+ forgegrit models list models
12
+ forgegrit usage requests used today
13
+ forgegrit whoami show the saved login
14
+ forgegrit logout forget the saved key
15
+ """
16
+
17
+ import argparse
18
+ import getpass
19
+ import json
20
+ import os
21
+ import stat
22
+ import sys
23
+
24
+ import requests
25
+
26
+ from . import __version__
27
+
28
+ DEFAULT_URL = "https://api-forgegrit.serveousercontent.com"
29
+ DEFAULT_MODEL = "claude-opus-5"
30
+ CONFIG_DIR = os.path.join(os.path.expanduser("~"), ".forgegrit")
31
+ CONFIG_FILE = os.path.join(CONFIG_DIR, "config.json")
32
+ TIMEOUT = 300
33
+
34
+ # ---------------------------------------------------------------- terminal colours
35
+ _COLOR = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
36
+
37
+
38
+ def _c(code, text):
39
+ return f"\033[{code}m{text}\033[0m" if _COLOR else text
40
+
41
+
42
+ def orange(t): return _c("38;5;208", t)
43
+ def green(t): return _c("32", t)
44
+ def red(t): return _c("31", t)
45
+ def dim(t): return _c("2", t)
46
+ def bold(t): return _c("1", t)
47
+
48
+
49
+ def die(msg, code=1):
50
+ print(red("error: ") + msg, file=sys.stderr)
51
+ sys.exit(code)
52
+
53
+
54
+ # ---------------------------------------------------------------- config
55
+ def load_config():
56
+ try:
57
+ with open(CONFIG_FILE, encoding="utf-8") as f:
58
+ return json.load(f)
59
+ except (OSError, ValueError):
60
+ return {}
61
+
62
+
63
+ def save_config(cfg):
64
+ os.makedirs(CONFIG_DIR, exist_ok=True)
65
+ with open(CONFIG_FILE, "w", encoding="utf-8") as f:
66
+ json.dump(cfg, f, indent=2)
67
+ try: # the file holds an API key — keep it private
68
+ os.chmod(CONFIG_FILE, stat.S_IRUSR | stat.S_IWUSR)
69
+ except OSError:
70
+ pass
71
+
72
+
73
+ def base_url(args, cfg):
74
+ return (getattr(args, "url", None) or os.environ.get("FORGEGRIT_URL")
75
+ or cfg.get("url") or DEFAULT_URL).rstrip("/")
76
+
77
+
78
+ def api_key(cfg):
79
+ key = os.environ.get("FORGEGRIT_API_KEY") or cfg.get("api_key")
80
+ if not key:
81
+ die("not logged in. Run `forgegrit login`, `forgegrit register`, "
82
+ "or `forgegrit api YOUR_KEY` first.")
83
+ return key
84
+
85
+
86
+ def mask(key):
87
+ return key[:8] + "…" + key[-4:] if key and len(key) > 14 else "(none)"
88
+
89
+
90
+ # ---------------------------------------------------------------- HTTP
91
+ def request(method, url, **kw):
92
+ kw.setdefault("timeout", TIMEOUT)
93
+ try:
94
+ r = requests.request(method, url, **kw)
95
+ except requests.ConnectionError:
96
+ die(f"can't reach {url.split('/v1')[0].split('/auth')[0]}. "
97
+ "The server or its tunnel may be offline.")
98
+ except requests.Timeout:
99
+ die("the server took too long to answer.")
100
+ return r
101
+
102
+
103
+ def body_of(r):
104
+ try:
105
+ return r.json()
106
+ except ValueError:
107
+ if r.status_code == 502 and not r.text.strip():
108
+ die("the tunnel returned an empty 502 — usually a brief hiccup. Try again.")
109
+ die(f"unexpected response (HTTP {r.status_code}): {r.text[:200]}")
110
+
111
+
112
+ def explain(r, data, auth=False):
113
+ msg = data.get("error") or f"HTTP {r.status_code}"
114
+ if auth: # login/register: a 401 means bad credentials, not a bad key
115
+ die(msg + (f"\n {data['how']}" if data.get("how") else ""))
116
+ hint = {
117
+ 401: "Your key was rejected. Log in again or run `forgegrit api NEW_KEY`.",
118
+ 403: None,
119
+ 429: "Daily limit reached. It resets at midnight.",
120
+ 502: "The model backend is down. Try again shortly.",
121
+ }.get(r.status_code)
122
+ if data.get("how"):
123
+ hint = data["how"]
124
+ die(msg + (f"\n {hint}" if hint else ""))
125
+
126
+
127
+ def show_notice(data):
128
+ if data.get("notice"):
129
+ print(dim("note: " + data["notice"]))
130
+
131
+
132
+ def prompt_credentials():
133
+ email = input("Email: ").strip()
134
+ password = getpass.getpass("Password: ")
135
+ if not email or not password:
136
+ die("email and password are both required.")
137
+ return email, password
138
+
139
+
140
+ # ---------------------------------------------------------------- auth commands
141
+ def cmd_login(args):
142
+ cfg = load_config()
143
+ url = base_url(args, cfg)
144
+ email, password = (args.email, args.password) if args.email and args.password \
145
+ else prompt_credentials()
146
+ payload = {"email": email, "password": password}
147
+
148
+ for _ in range(3):
149
+ r = request("POST", f"{url}/authenticate/email", json=payload)
150
+ data = body_of(r)
151
+ if r.ok:
152
+ break
153
+ if data.get("requires_2fa"):
154
+ if data.get("code_reused"):
155
+ print(dim("That code was already used — wait for the next one."))
156
+ payload["code"] = input("2FA code: ").strip()
157
+ continue
158
+ explain(r, data, auth=True)
159
+ else:
160
+ die("too many failed 2FA attempts.")
161
+
162
+ cfg.update(api_key=data["api_key"], email=email, url=url, method="login")
163
+ save_config(cfg)
164
+ print(green("✓ Logged in") + f" as {bold(email)}")
165
+ print(dim(f" key {mask(data['api_key'])} saved to {CONFIG_FILE}"))
166
+ show_notice(data)
167
+
168
+
169
+ def cmd_register(args):
170
+ cfg = load_config()
171
+ url = base_url(args, cfg)
172
+ email, password = (args.email, args.password) if args.email and args.password \
173
+ else prompt_credentials()
174
+ if not (args.email and args.password):
175
+ if getpass.getpass("Confirm password: ") != password:
176
+ die("passwords don't match.")
177
+
178
+ r = request("POST", f"{url}/register", json={"email": email, "password": password})
179
+ data = body_of(r)
180
+ if not r.ok:
181
+ explain(r, data, auth=True)
182
+ print(green("✓ Account created") + f" for {bold(email)}")
183
+ show_notice(data)
184
+
185
+ # log straight in so the user can chat immediately
186
+ r = request("POST", f"{url}/authenticate/email",
187
+ json={"email": email, "password": password})
188
+ data = body_of(r)
189
+ if not r.ok:
190
+ explain(r, data)
191
+ cfg.update(api_key=data["api_key"], email=email, url=url, method="register")
192
+ save_config(cfg)
193
+ print(dim(f" key {mask(data['api_key'])} saved to {CONFIG_FILE}"))
194
+
195
+
196
+ def cmd_api(args):
197
+ cfg = load_config()
198
+ url = base_url(args, cfg)
199
+ key = args.key or getpass.getpass("API key: ").strip()
200
+ if not key:
201
+ die("no key given.")
202
+ r = request("GET", f"{url}/v1/models", headers={"Authorization": f"Bearer {key}"})
203
+ data = body_of(r)
204
+ if not r.ok:
205
+ explain(r, data)
206
+ cfg.update(api_key=key, url=url, method="api")
207
+ cfg.pop("email", None)
208
+ save_config(cfg)
209
+ print(green("✓ API key saved") + dim(f" ({mask(key)}, {len(data.get('data', []))} models available)"))
210
+
211
+
212
+ def cmd_logout(args):
213
+ cfg = load_config()
214
+ for k in ("api_key", "email", "method"):
215
+ cfg.pop(k, None)
216
+ save_config(cfg)
217
+ print("Logged out. Your key is still valid on the server — rotate it on the site if it leaked.")
218
+
219
+
220
+ def cmd_whoami(args):
221
+ cfg = load_config()
222
+ key = os.environ.get("FORGEGRIT_API_KEY") or cfg.get("api_key")
223
+ print(f"server : {base_url(args, cfg)}")
224
+ print(f"account: {cfg.get('email') or dim('(API key only)')}")
225
+ print(f"method : {cfg.get('method', '—')}")
226
+ print(f"key : {mask(key)}")
227
+ print(f"model : {cfg.get('model', DEFAULT_MODEL)}")
228
+
229
+
230
+ # ---------------------------------------------------------------- info commands
231
+ def cmd_models(args):
232
+ cfg = load_config()
233
+ r = request("GET", f"{base_url(args, cfg)}/v1/models",
234
+ headers={"Authorization": f"Bearer {api_key(cfg)}"})
235
+ data = body_of(r)
236
+ if not r.ok:
237
+ explain(r, data)
238
+ current = cfg.get("model", DEFAULT_MODEL)
239
+ for m in sorted(data.get("data", []), key=lambda m: m["id"]):
240
+ mark = green("●") if m["id"] == current else " "
241
+ print(f" {mark} {m['id']}")
242
+ print(dim(f"\n{len(data.get('data', []))} models · default: {current} "
243
+ "· change with `forgegrit model ID`"))
244
+
245
+
246
+ def cmd_model(args):
247
+ cfg = load_config()
248
+ cfg["model"] = args.id
249
+ save_config(cfg)
250
+ print(green("✓ Default model: ") + args.id)
251
+
252
+
253
+ def cmd_usage(args):
254
+ cfg = load_config()
255
+ r = request("GET", f"{base_url(args, cfg)}/v1/usage",
256
+ headers={"Authorization": f"Bearer {api_key(cfg)}"})
257
+ data = body_of(r)
258
+ if not r.ok:
259
+ explain(r, data)
260
+ used, limit = data.get("requests_today", 0), data.get("rpd_limit", 0)
261
+ print(f"{used:,} / {limit:,} requests today · {data.get('remaining', 0):,} left")
262
+
263
+
264
+ # ---------------------------------------------------------------- chat
265
+ def stream_reply(url, key, model, messages):
266
+ """Send a chat request with streaming on; print tokens as they arrive.
267
+ Returns the full reply text."""
268
+ r = request("POST", f"{url}/v1/chat/completions",
269
+ headers={"Authorization": f"Bearer {key}"},
270
+ json={"model": model, "messages": messages, "stream": True},
271
+ stream=True)
272
+ if not r.ok:
273
+ explain(r, body_of(r))
274
+
275
+ out = []
276
+ for raw in r.iter_lines(decode_unicode=True):
277
+ if not raw or not raw.startswith("data:"):
278
+ continue
279
+ chunk = raw[5:].strip()
280
+ if chunk == "[DONE]":
281
+ break
282
+ try:
283
+ piece = json.loads(chunk)["choices"][0].get("delta", {}).get("content") or ""
284
+ except (ValueError, KeyError, IndexError):
285
+ continue
286
+ out.append(piece)
287
+ sys.stdout.write(piece)
288
+ sys.stdout.flush()
289
+ print()
290
+ return "".join(out)
291
+
292
+
293
+ def cmd_ask(args):
294
+ cfg = load_config()
295
+ prompt = " ".join(args.prompt) if args.prompt else sys.stdin.read()
296
+ if not prompt.strip():
297
+ die("nothing to ask.")
298
+ msgs = []
299
+ if args.system:
300
+ msgs.append({"role": "system", "content": args.system})
301
+ msgs.append({"role": "user", "content": prompt})
302
+ stream_reply(base_url(args, cfg), api_key(cfg),
303
+ args.model or cfg.get("model", DEFAULT_MODEL), msgs)
304
+
305
+
306
+ CHAT_HELP = """commands:
307
+ /model ID switch model
308
+ /system TEXT set a system prompt (clears history)
309
+ /clear start a new conversation
310
+ /exit quit (or Ctrl+D)"""
311
+
312
+
313
+ def cmd_chat(args):
314
+ cfg = load_config()
315
+ url, key = base_url(args, cfg), api_key(cfg)
316
+ model = args.model or cfg.get("model", DEFAULT_MODEL)
317
+ system = args.system
318
+ history = []
319
+
320
+ print(orange("⚡ ForgeGrit") + dim(f" · {model} · /help for commands"))
321
+ while True:
322
+ try:
323
+ line = input(bold("you › "))
324
+ except (EOFError, KeyboardInterrupt):
325
+ print()
326
+ return
327
+ text = line.strip()
328
+ if not text:
329
+ continue
330
+ if text in ("/exit", "/quit"):
331
+ return
332
+ if text == "/help":
333
+ print(dim(CHAT_HELP)); continue
334
+ if text == "/clear":
335
+ history = []; print(dim("(new conversation)")); continue
336
+ if text.startswith("/model"):
337
+ parts = text.split(maxsplit=1)
338
+ if len(parts) == 2:
339
+ model = parts[1]; print(dim(f"(model: {model})"))
340
+ else:
341
+ print(dim(f"(model: {model})"))
342
+ continue
343
+ if text.startswith("/system"):
344
+ system = text[7:].strip() or None
345
+ history = []
346
+ print(dim("(system prompt set, history cleared)" if system else "(system prompt cleared)"))
347
+ continue
348
+
349
+ history.append({"role": "user", "content": text})
350
+ msgs = ([{"role": "system", "content": system}] if system else []) + history
351
+ sys.stdout.write(orange(f"{model} › "))
352
+ sys.stdout.flush()
353
+ try:
354
+ reply = stream_reply(url, key, model, msgs)
355
+ except KeyboardInterrupt:
356
+ print(dim("\n(stopped)"))
357
+ history.pop()
358
+ continue
359
+ history.append({"role": "assistant", "content": reply})
360
+
361
+
362
+ # ---------------------------------------------------------------- entry point
363
+ def main(argv=None):
364
+ p = argparse.ArgumentParser(
365
+ prog="forgegrit",
366
+ description="ForgeGrit CLI — free frontier-model API from your terminal.",
367
+ epilog="Get an account at https://trey16885.github.io/forgegrit")
368
+ p.add_argument("--url", help=f"API base URL (default {DEFAULT_URL}, or $FORGEGRIT_URL)")
369
+ p.add_argument("-V", "--version", action="version", version=f"forgegrit-cli {__version__}")
370
+ sub = p.add_subparsers(dest="cmd", metavar="COMMAND")
371
+
372
+ s = sub.add_parser("login", help="log in with your ForgeGrit email and password")
373
+ s.add_argument("--email"); s.add_argument("--password")
374
+ s.set_defaults(func=cmd_login)
375
+
376
+ s = sub.add_parser("register", help="create a new ForgeGrit account")
377
+ s.add_argument("--email"); s.add_argument("--password")
378
+ s.set_defaults(func=cmd_register)
379
+
380
+ s = sub.add_parser("api", help="use an existing API key")
381
+ s.add_argument("key", nargs="?", help="your fgk_… key (prompted if omitted)")
382
+ s.set_defaults(func=cmd_api)
383
+
384
+ s = sub.add_parser("chat", help="interactive chat")
385
+ s.add_argument("-m", "--model"); s.add_argument("-s", "--system")
386
+ s.set_defaults(func=cmd_chat)
387
+
388
+ s = sub.add_parser("ask", help="ask one question (or pipe text in)")
389
+ s.add_argument("prompt", nargs="*")
390
+ s.add_argument("-m", "--model"); s.add_argument("-s", "--system")
391
+ s.set_defaults(func=cmd_ask)
392
+
393
+ s = sub.add_parser("models", help="list available models"); s.set_defaults(func=cmd_models)
394
+ s = sub.add_parser("model", help="set your default model")
395
+ s.add_argument("id"); s.set_defaults(func=cmd_model)
396
+ s = sub.add_parser("usage", help="requests used today"); s.set_defaults(func=cmd_usage)
397
+ s = sub.add_parser("whoami", help="show the saved login"); s.set_defaults(func=cmd_whoami)
398
+ s = sub.add_parser("logout", help="forget the saved key"); s.set_defaults(func=cmd_logout)
399
+
400
+ args = p.parse_args(argv)
401
+ if not getattr(args, "func", None):
402
+ p.print_help()
403
+ return
404
+ args.func(args)
405
+
406
+
407
+ if __name__ == "__main__":
408
+ main()
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 treyleo16
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,64 @@
1
+ Metadata-Version: 2.1
2
+ Name: forgegrit-cli
3
+ Version: 1.0.0
4
+ Summary: Command-line client for the ForgeGrit API: log in, register, and chat with every ForgeGrit model from your terminal.
5
+ Author: treyleo16
6
+ License: MIT
7
+ Project-URL: Homepage, https://trey16885.github.io/forgegrit
8
+ Keywords: forgegrit,llm,ai,cli,chat,api
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: Environment :: Console
11
+ Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Operating System :: OS Independent
13
+ Requires-Python: >=3.8
14
+ Requires-Dist: requests>=2.20
15
+ Description-Content-Type: text/markdown
16
+ License-File: LICENSE
17
+
18
+ # ForgeGrit CLI
19
+
20
+ Chat with every ForgeGrit model from your terminal. Free: no credit card, no trial credits.
21
+
22
+ ```bash
23
+ pip install forgegrit-cli
24
+ ```
25
+
26
+ ## Sign in — pick one
27
+
28
+ ```bash
29
+ forgegrit login # use your ForgeGrit email + password (2FA supported)
30
+ forgegrit register # create a new ForgeGrit account
31
+ forgegrit api fgk_yourkey # use an API key you already have
32
+ ```
33
+
34
+ Your key is saved to `~/.forgegrit/config.json`, readable only by you.
35
+
36
+ ## Use it
37
+
38
+ ```bash
39
+ forgegrit chat # interactive chat, replies stream in
40
+ forgegrit chat -m gpt-6-astra # pick a model
41
+ forgegrit ask "explain recursion" # one-shot question
42
+ cat notes.txt | forgegrit ask # pipe text in
43
+ forgegrit models # list models
44
+ forgegrit model claude-sonnet-5-5 # set your default model
45
+ forgegrit usage # requests used today
46
+ forgegrit whoami # show the saved login
47
+ forgegrit logout # forget the saved key
48
+ ```
49
+
50
+ Inside `chat`: `/model ID`, `/system TEXT`, `/clear`, `/help`, `/exit`.
51
+
52
+ ## Settings
53
+
54
+ | Variable | Does |
55
+ |---|---|
56
+ | `FORGEGRIT_API_KEY` | Use this key instead of the saved one |
57
+ | `FORGEGRIT_URL` | Point at a different ForgeGrit server |
58
+ | `NO_COLOR` | Turn off colours |
59
+
60
+ ## Note
61
+
62
+ Chats sent through ForgeGrit are logged by the server with your account, the time, your IP address, the model, your messages and the reply.
63
+
64
+ Get an account and read more at **https://trey16885.github.io/forgegrit**
@@ -0,0 +1,9 @@
1
+ forgegrit_cli/__init__.py,sha256=J-j-u0itpEFT6irdmWmixQqYMadNl1X91TxUmoiLHMI,22
2
+ forgegrit_cli/__main__.py,sha256=vBQ82334kX06ImDbFlPFgiBRiLIinwNk3z8Khs6hd74,31
3
+ forgegrit_cli/main.py,sha256=1wdLD6dGiPN4neEQByHHxwR0CO_M_AA44PKwFgzOd5I,14032
4
+ forgegrit_cli-1.0.0.dist-info/METADATA,sha256=tdKzjavky22s2OvxGxC01AuzprYstyqqBHBfiUzvs1U,2130
5
+ forgegrit_cli-1.0.0.dist-info/WHEEL,sha256=yp6aTco6OR3LM3n7UjmqeZ-4FXtQoVHarWCrFD1CyGU,87
6
+ forgegrit_cli-1.0.0.dist-info/entry_points.txt,sha256=jJyJqMYJ09tiee4e4LSgZK6oxUQDE7Mi8zvzMncJOqI,54
7
+ forgegrit_cli-1.0.0.dist-info/top_level.txt,sha256=9k0302y9bxuLzWIMOpgF0wxhq2ZEMp0eiQBVaVTusjM,14
8
+ forgegrit_cli-1.0.0.dist-info/LICENSE,sha256=bhsq1yPHxHBjccjl5zSTiNYwJN-har17OCw6DwKzJHw,1066
9
+ forgegrit_cli-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: forgegrit-build
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ forgegrit = forgegrit_cli.main:main
@@ -0,0 +1 @@
1
+ forgegrit_cli