llmkit-cli 0.1.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.
agent_cmd.py ADDED
@@ -0,0 +1,301 @@
1
+ import os
2
+ import sys
3
+ import json
4
+ import subprocess
5
+ import yaml
6
+ from pathlib import Path
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+ ROOT = Path(__file__).parent
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ MAX_STEPS = 10
14
+ WORKSPACE = Path.cwd().resolve()
15
+
16
+ TOOLS_SCHEMA = [
17
+ {"type": "function", "function": {
18
+ "name": "read_file",
19
+ "description": "Read a file's contents",
20
+ "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]},
21
+ }},
22
+ {"type": "function", "function": {
23
+ "name": "write_file",
24
+ "description": "Create or overwrite a file",
25
+ "parameters": {"type": "object", "properties": {
26
+ "path": {"type": "string"}, "content": {"type": "string"}
27
+ }, "required": ["path", "content"]},
28
+ }},
29
+ {"type": "function", "function": {
30
+ "name": "run_shell",
31
+ "description": "Run a shell command and return output",
32
+ "parameters": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]},
33
+ }},
34
+ {"type": "function", "function": {
35
+ "name": "list_dir",
36
+ "description": "List files and folders in a directory",
37
+ "parameters": {"type": "object", "properties": {"path": {"type": "string"}}, "required": []},
38
+ }},
39
+ ]
40
+
41
+ ALL_TOOL_NAMES = {t["function"]["name"] for t in TOOLS_SCHEMA}
42
+
43
+
44
+ def _active_tools(config):
45
+ allowed = config.get("tools")
46
+ if allowed is None:
47
+ return TOOLS_SCHEMA
48
+ allowed_set = set(allowed)
49
+ return [t for t in TOOLS_SCHEMA if t["function"]["name"] in allowed_set]
50
+
51
+
52
+ def _guard(path):
53
+ p = Path(path).resolve()
54
+ if not p.is_relative_to(WORKSPACE):
55
+ return None, f"Blocked: {p} is outside workspace"
56
+ return p, None
57
+
58
+
59
+ def _read_file(path):
60
+ p, err = _guard(path)
61
+ if err:
62
+ return err
63
+ try:
64
+ return p.read_text(encoding="utf-8")
65
+ except Exception as e:
66
+ return f"Error: {e}"
67
+
68
+
69
+ def _write_file(path, content):
70
+ p, err = _guard(path)
71
+ if err:
72
+ return err
73
+ try:
74
+ p.parent.mkdir(parents=True, exist_ok=True)
75
+ p.write_text(content, encoding="utf-8")
76
+ return f"Written: {p}"
77
+ except Exception as e:
78
+ return f"Error: {e}"
79
+
80
+
81
+ def _run_shell(command, approve):
82
+ if approve:
83
+ print(f"\n [approve] run: {command}")
84
+ if input(" Allow? [y/N]: ").strip().lower() != "y":
85
+ return "Skipped."
86
+ try:
87
+ result = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=30)
88
+ out = result.stdout.strip()
89
+ err = result.stderr.strip()
90
+ if result.returncode != 0:
91
+ return f"Exit {result.returncode}\n{err or out or '(no output)'}"
92
+ return out or err or "(no output)"
93
+ except subprocess.TimeoutExpired:
94
+ return "Error: timed out after 30s"
95
+ except Exception as e:
96
+ return f"Error: {e}"
97
+
98
+
99
+ def _list_dir(path="."):
100
+ p, err = _guard(path or ".")
101
+ if err:
102
+ return err
103
+ try:
104
+ entries = sorted(p.iterdir(), key=lambda e: (e.is_file(), e.name))
105
+ return "\n".join(f"{'[dir] ' if e.is_dir() else ' '}{e.name}" for e in entries)
106
+ except Exception as e:
107
+ return f"Error: {e}"
108
+
109
+
110
+ def _call_tool(name, args, approve):
111
+ if name == "read_file":
112
+ return _read_file(args.get("path", ""))
113
+ if name == "write_file":
114
+ return _write_file(args.get("path", ""), args.get("content", ""))
115
+ if name == "run_shell":
116
+ return _run_shell(args.get("command", ""), approve)
117
+ if name == "list_dir":
118
+ return _list_dir(args.get("path", "."))
119
+ return f"Error: unknown tool '{name}'"
120
+
121
+
122
+ def _check_budget(used, budget):
123
+ if budget is None:
124
+ return False
125
+ limit = budget.get("max_tokens_per_run")
126
+ warn_at = budget.get("warn_at")
127
+ if warn_at and used >= warn_at and (not limit or used < limit):
128
+ print(f" [budget] {used}/{limit or '?'} tokens used — approaching limit")
129
+ if limit is not None and used >= limit:
130
+ print(f"\nAgent: budget reached ({used}/{limit} tokens). Stopping.")
131
+ return True
132
+ return False
133
+
134
+
135
+ def _run_openai(task, config, plan_only, approve):
136
+ from providers.utils import openai_client
137
+ provider = config["provider"]
138
+ model = config["model"]
139
+ budget = config.get("budget")
140
+ tools = _active_tools(config)
141
+ client = openai_client(provider)
142
+ system = (
143
+ "Output a numbered step-by-step plan only. Do not call any tools. Be concise."
144
+ if plan_only else
145
+ "You are a coding agent. Complete the task using tools. Summarize when done."
146
+ )
147
+ messages = [{"role": "system", "content": system}, {"role": "user", "content": task}]
148
+ tokens_used = 0
149
+ for _ in range(MAX_STEPS):
150
+ kwargs = {"model": model, "messages": messages}
151
+ if not plan_only and tools:
152
+ kwargs["tools"] = tools
153
+ response = client.chat.completions.create(**kwargs)
154
+ tokens_used += getattr(response.usage, "total_tokens", 0)
155
+ msg = response.choices[0].message
156
+ assistant_msg = {"role": "assistant", "content": msg.content}
157
+ if msg.tool_calls:
158
+ assistant_msg["tool_calls"] = [
159
+ {"id": c.id, "type": "function",
160
+ "function": {"name": c.function.name, "arguments": c.function.arguments}}
161
+ for c in msg.tool_calls
162
+ ]
163
+ messages.append(assistant_msg)
164
+ if _check_budget(tokens_used, budget):
165
+ return
166
+ if not getattr(msg, "tool_calls", None):
167
+ if msg.content:
168
+ print(f"\nAgent: {msg.content}")
169
+ return
170
+ for call in msg.tool_calls:
171
+ try:
172
+ args = json.loads(call.function.arguments)
173
+ except Exception:
174
+ args = {}
175
+ print(f" -> {call.function.name}({args})")
176
+ try:
177
+ result = _call_tool(call.function.name, args, approve)
178
+ except Exception as e:
179
+ result = f"Error: {e}"
180
+ print(f" {str(result)[:200]}")
181
+ messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
182
+ print("Agent: reached max steps.")
183
+
184
+
185
+ def _run_anthropic(task, config, plan_only, approve):
186
+ import anthropic
187
+ key = os.getenv("ANTHROPIC_API_KEY")
188
+ if not key:
189
+ raise RuntimeError("API key not set for 'anthropic'. Add ANTHROPIC_API_KEY to your .env file.")
190
+ model = config["model"]
191
+ budget = config.get("budget")
192
+ tools = _active_tools(config)
193
+ client = anthropic.Anthropic(api_key=key)
194
+ system = (
195
+ "Output a numbered step-by-step plan only. Do not call any tools. Be concise."
196
+ if plan_only else
197
+ "You are a coding agent. Complete the task using tools. Summarize when done."
198
+ )
199
+ anthropic_tools = [
200
+ {"name": t["function"]["name"],
201
+ "description": t["function"]["description"],
202
+ "input_schema": t["function"]["parameters"]}
203
+ for t in tools
204
+ ]
205
+ messages = [{"role": "user", "content": task}]
206
+ tokens_used = 0
207
+ for _ in range(MAX_STEPS):
208
+ kwargs = {"model": model, "max_tokens": 4096, "system": system, "messages": messages}
209
+ if not plan_only and anthropic_tools:
210
+ kwargs["tools"] = anthropic_tools
211
+ response = client.messages.create(**kwargs)
212
+ tokens_used += (getattr(response.usage, "input_tokens", 0)
213
+ + getattr(response.usage, "output_tokens", 0))
214
+ if _check_budget(tokens_used, budget):
215
+ return
216
+ tool_results = []
217
+ for block in response.content:
218
+ if block.type == "tool_use":
219
+ print(f" -> {block.name}({block.input})")
220
+ result = _call_tool(block.name, block.input, approve)
221
+ print(f" {str(result)[:200]}")
222
+ tool_results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
223
+ elif block.type == "text" and block.text:
224
+ if response.stop_reason != "tool_use":
225
+ print(f"\nAgent: {block.text}")
226
+ if response.stop_reason == "end_turn" or not tool_results:
227
+ return
228
+ messages += [
229
+ {"role": "assistant", "content": response.content},
230
+ {"role": "user", "content": tool_results},
231
+ ]
232
+ print("Agent: reached max steps.")
233
+
234
+
235
+ def main():
236
+ argv = sys.argv[1:]
237
+ if argv and argv[0] == "agent":
238
+ argv = argv[1:]
239
+ plan_only = "--plan" in argv
240
+ approve = "--approve" in argv
241
+ task = " ".join(a for a in argv if not a.startswith("--")).strip()
242
+
243
+ if not task:
244
+ print("Usage: llmkit agent [--plan] [--approve] \"task\"")
245
+ sys.exit(1)
246
+
247
+ with open(Path.cwd() / "llm.yaml") as f:
248
+ config = yaml.safe_load(f)
249
+
250
+ provider = config.get("provider", "")
251
+
252
+ if provider == "local":
253
+ print("Agent requires function calling. Use openai/groq/anthropic/deepseek/together/mistral in llm.yaml.")
254
+ sys.exit(1)
255
+
256
+ mode_label = "plan" if plan_only else ("approve-agent" if approve else "agent")
257
+ print(f"llmkit {mode_label} | {provider} / {config['model']}")
258
+ budget = config.get("budget") or {}
259
+ if budget.get("max_tokens_per_run"):
260
+ parts = [f"max {budget['max_tokens_per_run']} tokens"]
261
+ if budget.get("warn_at"):
262
+ parts.append(f"warn at {budget['warn_at']}")
263
+ print(f"Budget: {', '.join(parts)}")
264
+ active = _active_tools(config)
265
+ unknown = set(config.get("tools") or []) - ALL_TOOL_NAMES
266
+ if unknown:
267
+ print(f"Warning: unknown tools in llm.yaml: {sorted(unknown)}")
268
+ if len(active) < len(TOOLS_SCHEMA):
269
+ print(f"Tools: {[t['function']['name'] for t in active]}")
270
+ print(f"Workspace: {WORKSPACE}\n")
271
+
272
+ def _run(cfg):
273
+ if cfg["provider"] == "anthropic":
274
+ _run_anthropic(task, cfg, plan_only, approve)
275
+ else:
276
+ _run_openai(task, cfg, plan_only, approve)
277
+
278
+ fallback_cfgs = [
279
+ {**config, "provider": fb["provider"], "model": fb["model"]}
280
+ for fb in config.get("fallback", [])
281
+ if fb.get("provider") and fb.get("model")
282
+ ]
283
+
284
+ try:
285
+ _run(config)
286
+ except Exception as primary_err:
287
+ if not fallback_cfgs:
288
+ raise
289
+ print(f" Primary provider failed: {primary_err}")
290
+ last_err = primary_err
291
+ for fb in fallback_cfgs:
292
+ print(f" Trying fallback: {fb['provider']} / {fb['model']}")
293
+ try:
294
+ _run(fb)
295
+ last_err = None
296
+ break
297
+ except Exception as err:
298
+ last_err = err
299
+ continue
300
+ if last_err:
301
+ raise last_err
check.py ADDED
@@ -0,0 +1,74 @@
1
+ import os
2
+ import sys
3
+ import yaml
4
+ from pathlib import Path
5
+ from dotenv import load_dotenv
6
+
7
+ load_dotenv()
8
+
9
+ ROOT = Path(__file__).parent
10
+ sys.path.insert(0, str(ROOT))
11
+
12
+ VALID_PROVIDERS = {"local", "openai", "anthropic", "groq", "together", "deepseek", "mistral"}
13
+
14
+ API_KEY_ENV = {
15
+ "openai": "OPENAI_API_KEY",
16
+ "anthropic": "ANTHROPIC_API_KEY",
17
+ "groq": "GROQ_API_KEY",
18
+ "together": "TOGETHER_API_KEY",
19
+ "deepseek": "DEEPSEEK_API_KEY",
20
+ "mistral": "MISTRAL_API_KEY",
21
+ }
22
+
23
+
24
+ def _load_config():
25
+ path = Path.cwd() / "llm.yaml"
26
+ if not path.exists():
27
+ print("FAIL llm.yaml not found — run: llmkit init")
28
+ sys.exit(1)
29
+ try:
30
+ with open(path) as f:
31
+ return yaml.safe_load(f)
32
+ except Exception as e:
33
+ print(f"FAIL llm.yaml parse error: {e}")
34
+ sys.exit(1)
35
+
36
+
37
+ def _check_key(provider):
38
+ if provider == "local":
39
+ import requests
40
+ try:
41
+ res = requests.get("http://localhost:11434/api/tags", timeout=3)
42
+ if res.status_code != 200:
43
+ print(f"FAIL Port 11434 responded but not Ollama (status {res.status_code})")
44
+ return False
45
+ print("OK Ollama reachable at localhost:11434")
46
+ return True
47
+ except Exception:
48
+ print("FAIL Ollama not reachable — run: ollama serve")
49
+ return False
50
+ env_var = API_KEY_ENV.get(provider, "")
51
+ key = os.getenv(env_var) if env_var else None
52
+ if not key:
53
+ print(f"FAIL {env_var} not set — add it to your .env file")
54
+ return False
55
+ print(f"OK {env_var} set ({len(key)} chars)")
56
+ return True
57
+
58
+
59
+ def main():
60
+ config = _load_config()
61
+ provider = config.get("provider")
62
+ model = config.get("model")
63
+ if not provider or not model:
64
+ print("FAIL llm.yaml missing required field (provider or model)")
65
+ sys.exit(1)
66
+ if provider not in VALID_PROVIDERS:
67
+ print(f"FAIL unknown provider '{provider}'")
68
+ sys.exit(1)
69
+ print(f" provider : {provider}")
70
+ print(f" model : {model}")
71
+ print(f" mode : {config.get('mode', 'chat')}")
72
+ if not _check_key(provider):
73
+ sys.exit(1)
74
+ print("OK all checks passed")
cli.py ADDED
@@ -0,0 +1,98 @@
1
+ import os
2
+ import sys
3
+ import importlib
4
+ import yaml
5
+ from dotenv import load_dotenv
6
+ from pathlib import Path
7
+
8
+ load_dotenv()
9
+
10
+ ROOT = Path(__file__).parent
11
+ sys.path.insert(0, str(ROOT))
12
+
13
+ GIT_COMMANDS = {"commit", "pr", "review"}
14
+ PHASE_A_CMDS = {"check", "lock", "init", "env"}
15
+ KNOWN_COMMANDS = GIT_COMMANDS | PHASE_A_CMDS | {"agent", "run"}
16
+
17
+ providers_map = {
18
+ "local": "providers.local",
19
+ "openai": "providers.openai",
20
+ "anthropic": "providers.anthropic",
21
+ "groq": "providers.groq",
22
+ "together": "providers.together",
23
+ "deepseek": "providers.deepseek",
24
+ "mistral": "providers.mistral",
25
+ }
26
+
27
+
28
+ def _usage():
29
+ print("Usage: llmkit run \"your question here\"")
30
+ print(" llmkit commit|pr|review|check|lock|init|env|agent [--plan|--approve]")
31
+
32
+
33
+ def main():
34
+ if len(sys.argv) < 2:
35
+ _usage()
36
+ sys.exit(1)
37
+
38
+ cmd = sys.argv[1]
39
+
40
+ if cmd not in KNOWN_COMMANDS:
41
+ print(f"Unknown command: {cmd}")
42
+ _usage()
43
+ sys.exit(1)
44
+
45
+ if cmd in GIT_COMMANDS:
46
+ import git_cmds
47
+ ok = getattr(git_cmds, cmd)()
48
+ sys.exit(0 if ok is not False else 1)
49
+
50
+ if cmd in PHASE_A_CMDS:
51
+ importlib.import_module(cmd).main()
52
+ sys.exit(0)
53
+
54
+ if cmd == "agent":
55
+ import agent_cmd
56
+ agent_cmd.main()
57
+ sys.exit(0)
58
+
59
+ if cmd == "run":
60
+ if len(sys.argv) < 3:
61
+ print("Usage: llmkit run \"your question here\"")
62
+ sys.exit(1)
63
+ sys.argv = [sys.argv[0]] + sys.argv[2:]
64
+
65
+ prompt = " ".join(sys.argv[1:])
66
+
67
+ with open(Path.cwd() / "llm.yaml") as f:
68
+ config = yaml.safe_load(f)
69
+
70
+ provider = config["provider"]
71
+ model = config["model"]
72
+
73
+ if provider not in providers_map:
74
+ print(f"Unknown provider: {provider}")
75
+ sys.exit(1)
76
+
77
+ send_message = importlib.import_module(providers_map[provider]).send_message
78
+ try:
79
+ print(send_message(prompt, model))
80
+ except Exception as primary_err:
81
+ for fb in config.get("fallback", []):
82
+ fb_provider = fb.get("provider", "")
83
+ fb_model = fb.get("model", "")
84
+ if not fb_provider or fb_provider not in providers_map:
85
+ continue
86
+ try:
87
+ fb_send = importlib.import_module(providers_map[fb_provider]).send_message
88
+ print(fb_send(prompt, fb_model))
89
+ raise SystemExit(0)
90
+ except SystemExit:
91
+ raise
92
+ except Exception:
93
+ continue
94
+ raise primary_err
95
+
96
+
97
+ if __name__ == "__main__":
98
+ main()
env.py ADDED
@@ -0,0 +1,65 @@
1
+ import os
2
+ import sys
3
+ from pathlib import Path
4
+
5
+ ROOT = Path(__file__).parent
6
+
7
+ PROVIDERS = {
8
+ "openai": "OPENAI_API_KEY",
9
+ "anthropic": "ANTHROPIC_API_KEY",
10
+ "groq": "GROQ_API_KEY",
11
+ "together": "TOGETHER_API_KEY",
12
+ "deepseek": "DEEPSEEK_API_KEY",
13
+ "mistral": "MISTRAL_API_KEY",
14
+ }
15
+
16
+
17
+ def _read_env():
18
+ env_path = Path.cwd() / ".env"
19
+ if not env_path.exists():
20
+ return {}
21
+ lines = env_path.read_text().splitlines()
22
+ result = {}
23
+ for line in lines:
24
+ line = line.strip()
25
+ if line and not line.startswith("#") and "=" in line:
26
+ k, _, v = line.partition("=")
27
+ result[k.strip()] = v.strip()
28
+ return result
29
+
30
+
31
+ def _write_env(data):
32
+ env_path = Path.cwd() / ".env"
33
+ lines = ["# llmkit environment — do not commit this file"]
34
+ for k, v in sorted(data.items()):
35
+ lines.append(f"{k}={v}")
36
+ env_path.write_text("\n".join(lines) + "\n")
37
+
38
+
39
+ def main():
40
+ existing = _read_env()
41
+ print("llmkit env — set API keys for your providers")
42
+ print("Press Enter to keep existing value, '-' to clear.\n")
43
+
44
+ updated = dict(existing)
45
+ changed = False
46
+
47
+ for provider, var in PROVIDERS.items():
48
+ current = existing.get(var, "")
49
+ display = f"{current[:8]}..." if len(current) > 8 else (current or "not set")
50
+ val = input(f" {var} [{display}]: ").strip()
51
+ if val == "-":
52
+ updated.pop(var, None)
53
+ changed = True
54
+ elif val:
55
+ updated[var] = val
56
+ changed = True
57
+
58
+ if not changed:
59
+ print("\nNo changes.")
60
+ return
61
+
62
+ _write_env(updated)
63
+ set_keys = [k for k in PROVIDERS.values() if updated.get(k)]
64
+ print(f"\nWritten .env ({len(set_keys)} keys set)")
65
+ print("Make sure .env is in your .gitignore.")
git.py ADDED
@@ -0,0 +1,2 @@
1
+ # Renamed to git_cmds.py to avoid shadowing the gitpython package.
2
+ # This file is intentionally empty.
git_cmds.py ADDED
@@ -0,0 +1,140 @@
1
+ import os
2
+ import sys
3
+ import subprocess
4
+ import importlib
5
+ import yaml
6
+ from pathlib import Path
7
+ from dotenv import load_dotenv
8
+
9
+ load_dotenv()
10
+
11
+ ROOT = Path(__file__).parent
12
+ sys.path.insert(0, str(ROOT))
13
+
14
+ MAX_DIFF_CHARS = 12000
15
+
16
+ PROVIDERS_MAP = {
17
+ "local": "providers.local",
18
+ "openai": "providers.openai",
19
+ "anthropic": "providers.anthropic",
20
+ "groq": "providers.groq",
21
+ "together": "providers.together",
22
+ "deepseek": "providers.deepseek",
23
+ "mistral": "providers.mistral",
24
+ }
25
+
26
+
27
+ def _config():
28
+ with open(Path.cwd() / "llm.yaml") as f:
29
+ return yaml.safe_load(f)
30
+
31
+
32
+ def _send(prompt):
33
+ config = _config()
34
+ provider = config["provider"]
35
+ model = config["model"]
36
+ if provider not in PROVIDERS_MAP:
37
+ print(f"Unknown provider: {provider}")
38
+ sys.exit(1)
39
+ send_message = importlib.import_module(PROVIDERS_MAP[provider]).send_message
40
+ try:
41
+ return send_message(prompt, model)
42
+ except Exception as primary_err:
43
+ for fb in config.get("fallback", []):
44
+ fb_provider = fb.get("provider", "")
45
+ fb_model = fb.get("model", "")
46
+ if not fb_provider or fb_provider not in PROVIDERS_MAP:
47
+ continue
48
+ print(f" Fallback: {fb_provider} / {fb_model}")
49
+ try:
50
+ fb_send = importlib.import_module(PROVIDERS_MAP[fb_provider]).send_message
51
+ return fb_send(prompt, fb_model)
52
+ except Exception:
53
+ continue
54
+ raise primary_err
55
+
56
+
57
+ def _diff(cached=True):
58
+ args = ["git", "diff"] + (["--cached"] if cached else [])
59
+ result = subprocess.run(args, capture_output=True, text=True)
60
+ if result.returncode != 0:
61
+ err = result.stderr.strip() or f"git diff exited with code {result.returncode}"
62
+ return None, err
63
+ return result.stdout.strip(), None
64
+
65
+
66
+ def _branch_diff():
67
+ # Try to detect default branch from origin/HEAD
68
+ sym = subprocess.run(
69
+ ["git", "symbolic-ref", "--short", "refs/remotes/origin/HEAD"],
70
+ capture_output=True, text=True
71
+ )
72
+ origin_base = sym.stdout.strip().split("/")[-1] if sym.returncode == 0 else None
73
+
74
+ bases = list(dict.fromkeys(filter(None, [origin_base, "main", "master"])))
75
+ result = None
76
+ for base in bases:
77
+ result = subprocess.run(
78
+ ["git", "diff", f"{base}...HEAD"],
79
+ capture_output=True, text=True
80
+ )
81
+ if result.returncode == 0:
82
+ return result.stdout.strip(), None
83
+ if result is None:
84
+ return None, "no base branch to diff against"
85
+ last_err = result.stderr.strip() if result.returncode != 0 else None
86
+ return None, last_err
87
+
88
+
89
+ def commit():
90
+ diff, err = _diff(cached=True)
91
+ if err:
92
+ print(f"git error: {err}")
93
+ return False
94
+ if not diff:
95
+ print("Nothing staged. Run: git add <files>")
96
+ return False
97
+ prompt = (
98
+ "Write a concise git commit message for this diff. "
99
+ "Output only the message, no explanation.\n\n"
100
+ + diff[:MAX_DIFF_CHARS]
101
+ )
102
+ print(_send(prompt))
103
+ return True
104
+
105
+
106
+ def pr():
107
+ diff, err = _branch_diff()
108
+ if err:
109
+ print(f"git error: {err}")
110
+ return False
111
+ if not diff:
112
+ print("No diff found vs default branch.")
113
+ return False
114
+ prompt = (
115
+ "Write a GitHub pull request title and body for this diff. Be concise.\n\n"
116
+ + diff[:MAX_DIFF_CHARS]
117
+ )
118
+ print(_send(prompt))
119
+ return True
120
+
121
+
122
+ def review():
123
+ diff, err = _diff(cached=True)
124
+ if err:
125
+ print(f"git error: {err}")
126
+ return False
127
+ if not diff:
128
+ diff, err = _diff(cached=False)
129
+ if err:
130
+ print(f"git error: {err}")
131
+ return False
132
+ if not diff:
133
+ print("No changes to review.")
134
+ return False
135
+ prompt = (
136
+ "Review this code diff. Flag bugs, security issues, and improvements. Be concise.\n\n"
137
+ + diff[:MAX_DIFF_CHARS]
138
+ )
139
+ print(_send(prompt))
140
+ return True
init.py ADDED
@@ -0,0 +1,63 @@
1
+ import sys
2
+ import yaml
3
+ from pathlib import Path
4
+
5
+ ROOT = Path(__file__).parent
6
+ sys.path.insert(0, str(ROOT))
7
+
8
+ PROVIDERS = ["local", "openai", "anthropic", "groq", "together", "deepseek", "mistral"]
9
+
10
+ DEFAULTS = {
11
+ "local": "llama3.2",
12
+ "openai": "gpt-4o-mini",
13
+ "anthropic": "claude-3-5-haiku-20241022",
14
+ "groq": "llama-3.3-70b-versatile",
15
+ "together": "meta-llama/Llama-3.3-70B-Instruct-Turbo",
16
+ "deepseek": "deepseek-chat",
17
+ "mistral": "mistral-small-latest",
18
+ }
19
+
20
+ API_KEY_ENV = {
21
+ "openai": "OPENAI_API_KEY",
22
+ "anthropic": "ANTHROPIC_API_KEY",
23
+ "groq": "GROQ_API_KEY",
24
+ "together": "TOGETHER_API_KEY",
25
+ "deepseek": "DEEPSEEK_API_KEY",
26
+ "mistral": "MISTRAL_API_KEY",
27
+ }
28
+
29
+
30
+ def _ask(label, default):
31
+ val = input(f" {label} [{default}]: ").strip()
32
+ return val if val else default
33
+
34
+
35
+ def _write_config(provider, model, mode):
36
+ config = {"provider": provider, "model": model}
37
+ if mode != "chat":
38
+ config["mode"] = mode
39
+ with open(Path.cwd() / "llm.yaml", "w") as f:
40
+ yaml.dump(config, f, default_flow_style=False, sort_keys=False)
41
+ print(f"\nCreated llm.yaml ({provider} / {model} mode={mode})")
42
+ env_var = API_KEY_ENV.get(provider, "")
43
+ if env_var:
44
+ print(f"Add {env_var} to your .env file before running.")
45
+
46
+
47
+ def main():
48
+ config_path = Path.cwd() / "llm.yaml"
49
+ if config_path.exists():
50
+ if input("llm.yaml already exists. Overwrite? [y/N]: ").strip().lower() != "y":
51
+ print("Aborted.")
52
+ return
53
+ print(f"Providers: {', '.join(PROVIDERS)}")
54
+ provider = _ask("provider", "groq")
55
+ if provider not in PROVIDERS:
56
+ print(f"Unknown provider '{provider}'. Choose from: {', '.join(PROVIDERS)}")
57
+ sys.exit(1)
58
+ model = _ask("model", DEFAULTS[provider])
59
+ mode = _ask("mode (chat / coding / autonomous)", "chat")
60
+ if mode not in ("chat", "coding", "autonomous"):
61
+ print(f"Unknown mode '{mode}'. Choose: chat, coding, autonomous")
62
+ sys.exit(1)
63
+ _write_config(provider, model, mode)
@@ -0,0 +1,193 @@
1
+ Metadata-Version: 2.4
2
+ Name: llmkit-cli
3
+ Version: 0.1.0
4
+ Summary: Run any LLM with one config file. No framework lock-in.
5
+ License: MIT
6
+ Project-URL: Homepage, https://github.com/abinzagr/llmkit
7
+ Project-URL: Repository, https://github.com/abinzagr/llmkit
8
+ Keywords: llm,ai,openai,anthropic,groq,cli
9
+ Classifier: Programming Language :: Python :: 3
10
+ Classifier: License :: OSI Approved :: MIT License
11
+ Classifier: Operating System :: OS Independent
12
+ Requires-Python: >=3.10
13
+ Description-Content-Type: text/markdown
14
+ Requires-Dist: pyyaml
15
+ Requires-Dist: python-dotenv
16
+ Requires-Dist: requests
17
+ Requires-Dist: openai
18
+ Requires-Dist: anthropic
19
+ Requires-Dist: groq
20
+ Provides-Extra: all
21
+ Requires-Dist: together; extra == "all"
22
+ Requires-Dist: mistralai; extra == "all"
23
+ Requires-Dist: chromadb; extra == "all"
24
+ Requires-Dist: mcp; extra == "all"
25
+
26
+ # llmkit
27
+
28
+ Run any LLM — local or via API — with one config file. No framework lock-in. Works on Windows, Mac, Linux.
29
+
30
+ ```bash
31
+ llmkit run "explain this codebase in one sentence"
32
+ ```
33
+
34
+ ## Install
35
+
36
+ ```bash
37
+ # Linux
38
+ bash install.sh
39
+
40
+ # Mac
41
+ bash install.mac.sh
42
+
43
+ # Windows (PowerShell as admin)
44
+ ./install.ps1
45
+ ```
46
+
47
+ Each script installs deps, sets up Ollama if needed, and adds `llmkit` to your PATH.
48
+
49
+ ## Configure
50
+
51
+ Edit `llm.yaml` (or run `llmkit init` for a guided setup):
52
+
53
+ ```yaml
54
+ provider: groq # local | openai | anthropic | groq | together | deepseek | mistral
55
+ model: llama-3.3-70b-versatile
56
+ mode: chat
57
+ ```
58
+
59
+ For API providers, copy `.env.example` to `.env` and add your key.
60
+
61
+ ## Commands
62
+
63
+ ```bash
64
+ # One-shot prompt
65
+ llmkit run "explain this codebase in one sentence"
66
+
67
+ # Validate config + check provider reachability
68
+ llmkit check
69
+
70
+ # Interactive wizard to create llm.yaml
71
+ llmkit init
72
+
73
+ # Pin current model to llm.lock (commit this file)
74
+ llmkit lock
75
+
76
+ # Generate a commit message from staged changes
77
+ llmkit commit
78
+
79
+ # Generate a PR title + body vs main/master
80
+ llmkit pr
81
+
82
+ # Review staged/unstaged diff for bugs
83
+ llmkit review
84
+
85
+ # Run a coding agent on a task
86
+ llmkit agent "refactor this module to use dataclasses"
87
+
88
+ # Plan only — no tools executed, just a numbered plan
89
+ llmkit agent --plan "add pagination to the API"
90
+
91
+ # Approve mode — confirm each shell command before it runs
92
+ llmkit agent --approve "run the test suite and fix any failures"
93
+ ```
94
+
95
+ ## Examples
96
+
97
+ ```bash
98
+ # Chat
99
+ python examples/chat.py
100
+ node examples/chat.js # all providers including Anthropic
101
+
102
+ # Streaming
103
+ python examples/stream.py
104
+ node examples/stream.js # all providers including Anthropic
105
+
106
+ # Function calling / tools
107
+ python examples/tools.py
108
+
109
+ # Vision (image input)
110
+ python examples/vision.py
111
+
112
+ # Multi-round conversation
113
+ python examples/multiround.py
114
+
115
+ # Embeddings + cosine similarity
116
+ python examples/embed.py
117
+
118
+ # Coding agent
119
+ python examples/agent.py
120
+
121
+ # MCP agent (connects to MCP servers defined in llm.yaml)
122
+ python examples/mcp_agent.py
123
+ ```
124
+
125
+ ## Local models (via Ollama)
126
+
127
+ | Model | Config |
128
+ |---|---|
129
+ | Llama 4 | `model: llama4` |
130
+ | Qwen 3 | `model: qwen3` |
131
+ | DeepSeek R1 | `model: deepseek-r1` |
132
+ | Mistral | `model: mistral` |
133
+ | Phi-4 | `model: phi4` |
134
+ | Gemma 3 | `model: gemma3` |
135
+
136
+ ## API providers
137
+
138
+ | Provider | Env key | Fast cheap model |
139
+ |---|---|---|
140
+ | OpenAI | `OPENAI_API_KEY` | `gpt-4o-mini` |
141
+ | Anthropic | `ANTHROPIC_API_KEY` | `claude-3-5-haiku-20241022` |
142
+ | Groq | `GROQ_API_KEY` | `llama-3.1-8b-instant` |
143
+ | Together | `TOGETHER_API_KEY` | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
144
+ | DeepSeek | `DEEPSEEK_API_KEY` | `deepseek-chat` |
145
+ | Mistral | `MISTRAL_API_KEY` | `mistral-small-latest` |
146
+
147
+ ## 5-minute team setup
148
+
149
+ ```bash
150
+ # 1 — clone
151
+ git clone https://github.com/your-org/llmkit
152
+ cd llmkit
153
+
154
+ # 2 — install deps + register llmkit command
155
+ bash install.sh # Mac: bash install.mac.sh | Windows: ./install.ps1
156
+
157
+ # 3 — set one API key (Groq free tier works)
158
+ echo "GROQ_API_KEY=your_key_here" > .env
159
+
160
+ # 4 — verify everything is wired up
161
+ llmkit check
162
+
163
+ # 5 — run your first prompt
164
+ llmkit run "explain this codebase in one sentence"
165
+ ```
166
+
167
+ No server. No IDE extension. No code to write. Switch providers by editing one line in `llm.yaml`.
168
+
169
+ ## llm.lock
170
+
171
+ Run `llmkit lock` to pin your model runtime. Commit `llm.lock` alongside your code:
172
+
173
+ ```yaml
174
+ # Auto-generated — commit this file to pin your model runtime
175
+ locked_at: "2026-06-26T12:00:00Z"
176
+ provider: groq
177
+ model: llama-3.3-70b-versatile
178
+ mode: chat
179
+ ```
180
+
181
+ Same idea as `package-lock.json` — reproducible environments, no surprise model swaps between teammates.
182
+
183
+ ## Switch providers
184
+
185
+ Change one line in `llm.yaml`, run `llmkit check`, done:
186
+
187
+ ```yaml
188
+ # was: provider: groq
189
+ provider: anthropic
190
+ model: claude-3-5-haiku-20241022
191
+ ```
192
+
193
+ No code changes. No redeploy. Works in CI too.
@@ -0,0 +1,22 @@
1
+ agent_cmd.py,sha256=9aHCv4vwNq2momIciawNyZiFBAQbVRnEzMKn3Wn_8Ew,10679
2
+ check.py,sha256=Q43e1z6HKAvhwYV0JUmHgfOEOwjoDjG-rrrdeS1eXzk,2226
3
+ cli.py,sha256=NRigdwC3aseiE9MDNu5TNlyC3ul8FDOi6Hu5HLwrUBY,2543
4
+ env.py,sha256=eZLfIzrRKYmGsOk1oxErD5MBm7Hf2wCHmVgiazcDR1s,1799
5
+ git.py,sha256=5JwhUKT6sYDkcCthUL3tvwVLILog6accr0Wi8MiBOUA,103
6
+ git_cmds.py,sha256=9u9DOojmmJ2-Ao6jXDlpMPJZYaLPvgTbVR2Gk6Zq2NQ,3969
7
+ init.py,sha256=ZU_Obczr7iF4FppzXh5AMRIx5NXB5m5FBpHe-l73tqI,2062
8
+ lock.py,sha256=7YBj_3y8pA2ZTEQydkG9m03Yx-wDXaGZC5CGX2S4Mb8,1434
9
+ providers/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
10
+ providers/anthropic.py,sha256=ybADRUQHLpW7u07PcMdGeEqvFdcY0jf59DC_w_VRr40,476
11
+ providers/deepseek.py,sha256=3GX8Aaj_wAkdK0HOP7PJmEeP3w1-kzRrHy6cvVTWJ0k,493
12
+ providers/groq.py,sha256=Tl80ZEOjbJ7p6zV3Wjre5VrGWhf7xfYeao6DjVHvo1g,442
13
+ providers/local.py,sha256=Fe0GpSTnpNUqKbL6zbm9RgpMCSfkMpQuQ7lT8CApD8M,792
14
+ providers/mistral.py,sha256=PO-PzwmbNx3yvsJLmFaQ746ycceB354BJ4c6mcLuIR0,313
15
+ providers/openai.py,sha256=N8i4g2sXXJ4WiAzlkcetTDNT0EbQ2ZpRHfE5QPTFAD4,445
16
+ providers/together.py,sha256=2u1njEat6fDqGqWRmbzZt5AoiXOGgjX2RctWtLcseEo,485
17
+ providers/utils.py,sha256=NCJSgRdSctKZsv3eY6YsbUPNYSnxWIUoAs7buThnpwc,872
18
+ llmkit_cli-0.1.0.dist-info/METADATA,sha256=ALvlMLKmiqTKc-igCHw-BrB0VGV_4G6Rs2csetB5fvU,4856
19
+ llmkit_cli-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
20
+ llmkit_cli-0.1.0.dist-info/entry_points.txt,sha256=6UgaPStSt-qiZ5aL1xRi-xoxSSqOlzF8zFsSxnGSWhA,36
21
+ llmkit_cli-0.1.0.dist-info/top_level.txt,sha256=7lMT6GaGf6jeGLt5kzJ4-yaJfDTY3E6yylLV5JnJjgI,57
22
+ llmkit_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ llmkit = cli:main
@@ -0,0 +1,9 @@
1
+ agent_cmd
2
+ check
3
+ cli
4
+ env
5
+ git
6
+ git_cmds
7
+ init
8
+ lock
9
+ providers
lock.py ADDED
@@ -0,0 +1,43 @@
1
+ import sys
2
+ import yaml
3
+ from datetime import datetime, timezone
4
+ from pathlib import Path
5
+
6
+ ROOT = Path(__file__).parent
7
+ LLMKIT_VERSION = "1.1.0"
8
+
9
+
10
+ def _load_config():
11
+ path = Path.cwd() / "llm.yaml"
12
+ if not path.exists():
13
+ print("FAIL llm.yaml not found — run: llmkit init")
14
+ sys.exit(1)
15
+ try:
16
+ with open(path) as f:
17
+ config = yaml.safe_load(f)
18
+ except Exception as e:
19
+ print(f"FAIL llm.yaml parse error: {e}")
20
+ sys.exit(1)
21
+ if not config.get("provider") or not config.get("model"):
22
+ print("FAIL llm.yaml missing provider or model")
23
+ sys.exit(1)
24
+ return config
25
+
26
+
27
+ def main():
28
+ config = _load_config()
29
+ data = {
30
+ "locked_at": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
31
+ "provider": config["provider"],
32
+ "model": config["model"],
33
+ "mode": config.get("mode", "chat"),
34
+ "llmkit_version": LLMKIT_VERSION,
35
+ }
36
+ lock_path = Path.cwd() / "llm.lock"
37
+ with open(lock_path, "w") as f:
38
+ f.write("# Auto-generated by: llmkit lock\n")
39
+ f.write("# Commit this file to pin your model runtime.\n\n")
40
+ yaml.dump(data, f, default_flow_style=False, sort_keys=False)
41
+ print(f"Locked {config['provider']} / {config['model']} ({data['mode']})")
42
+ print(f"Written {lock_path}")
43
+ print("Tip: commit llm.lock to pin your model runtime across the team.")
providers/__init__.py ADDED
File without changes
providers/anthropic.py ADDED
@@ -0,0 +1,14 @@
1
+ import os
2
+ import anthropic
3
+
4
+ def send_message(prompt, model="claude-3-5-haiku-20241022"):
5
+ key = os.getenv("ANTHROPIC_API_KEY")
6
+ if not key:
7
+ raise RuntimeError("API key not set for 'anthropic'. Add ANTHROPIC_API_KEY to your .env file.")
8
+ client = anthropic.Anthropic(api_key=key)
9
+ message = client.messages.create(
10
+ model=model,
11
+ max_tokens=4096,
12
+ messages=[{"role": "user", "content": prompt}]
13
+ )
14
+ return message.content[0].text
providers/deepseek.py ADDED
@@ -0,0 +1,13 @@
1
+ import os
2
+ from openai import OpenAI
3
+
4
+ def send_message(prompt, model="deepseek-chat"):
5
+ key = os.getenv("DEEPSEEK_API_KEY")
6
+ if not key:
7
+ raise RuntimeError("API key not set for 'deepseek'. Add DEEPSEEK_API_KEY to your .env file.")
8
+ client = OpenAI(api_key=key, base_url="https://api.deepseek.com/v1")
9
+ response = client.chat.completions.create(
10
+ model=model,
11
+ messages=[{"role": "user", "content": prompt}]
12
+ )
13
+ return response.choices[0].message.content
providers/groq.py ADDED
@@ -0,0 +1,13 @@
1
+ import os
2
+ from groq import Groq
3
+
4
+ def send_message(prompt, model="llama-3.1-8b-instant"):
5
+ key = os.getenv("GROQ_API_KEY")
6
+ if not key:
7
+ raise RuntimeError("API key not set for 'groq'. Add GROQ_API_KEY to your .env file.")
8
+ client = Groq(api_key=key)
9
+ response = client.chat.completions.create(
10
+ model=model,
11
+ messages=[{"role": "user", "content": prompt}]
12
+ )
13
+ return response.choices[0].message.content
providers/local.py ADDED
@@ -0,0 +1,20 @@
1
+ import requests
2
+
3
+ def send_message(prompt, model="llama3.2"):
4
+ try:
5
+ response = requests.post(
6
+ "http://localhost:11434/api/generate",
7
+ json={"model": model, "prompt": prompt, "stream": False},
8
+ timeout=120
9
+ )
10
+ data = response.json()
11
+ if "error" in data:
12
+ raise RuntimeError(f"Ollama error: {data['error']}")
13
+ text = data.get("response")
14
+ if text is None:
15
+ raise RuntimeError(f"Unexpected Ollama response: {data}")
16
+ return text
17
+ except requests.exceptions.ConnectionError:
18
+ raise RuntimeError("Ollama is not running. Start it with: ollama serve")
19
+ except requests.exceptions.Timeout:
20
+ raise RuntimeError("Ollama timed out. Try a smaller model or increase timeout.")
providers/mistral.py ADDED
@@ -0,0 +1,9 @@
1
+ from providers.utils import openai_client
2
+
3
+ def send_message(prompt, model="mistral-small-latest"):
4
+ client = openai_client("mistral")
5
+ response = client.chat.completions.create(
6
+ model=model,
7
+ messages=[{"role": "user", "content": prompt}]
8
+ )
9
+ return response.choices[0].message.content
providers/openai.py ADDED
@@ -0,0 +1,13 @@
1
+ import os
2
+ from openai import OpenAI
3
+
4
+ def send_message(prompt, model="gpt-4o-mini"):
5
+ key = os.getenv("OPENAI_API_KEY")
6
+ if not key:
7
+ raise RuntimeError("API key not set for 'openai'. Add OPENAI_API_KEY to your .env file.")
8
+ client = OpenAI(api_key=key)
9
+ response = client.chat.completions.create(
10
+ model=model,
11
+ messages=[{"role": "user", "content": prompt}]
12
+ )
13
+ return response.choices[0].message.content
providers/together.py ADDED
@@ -0,0 +1,13 @@
1
+ import os
2
+ from together import Together
3
+
4
+ def send_message(prompt, model="meta-llama/Llama-3.3-70B-Instruct-Turbo"):
5
+ key = os.getenv("TOGETHER_API_KEY")
6
+ if not key:
7
+ raise RuntimeError("API key not set for 'together'. Add TOGETHER_API_KEY to your .env file.")
8
+ client = Together(api_key=key)
9
+ response = client.chat.completions.create(
10
+ model=model,
11
+ messages=[{"role": "user", "content": prompt}]
12
+ )
13
+ return response.choices[0].message.content
providers/utils.py ADDED
@@ -0,0 +1,28 @@
1
+ import os
2
+
3
+ BASE_URLS = {
4
+ "groq": "https://api.groq.com/openai/v1",
5
+ "deepseek": "https://api.deepseek.com/v1",
6
+ "together": "https://api.together.xyz/v1",
7
+ "mistral": "https://api.mistral.ai/v1",
8
+ }
9
+
10
+ API_KEY_NAMES = {
11
+ "openai": "OPENAI_API_KEY",
12
+ "groq": "GROQ_API_KEY",
13
+ "deepseek": "DEEPSEEK_API_KEY",
14
+ "together": "TOGETHER_API_KEY",
15
+ "mistral": "MISTRAL_API_KEY",
16
+ }
17
+
18
+ def openai_client(provider):
19
+ from openai import OpenAI
20
+ env_var = API_KEY_NAMES.get(provider, "")
21
+ key = os.getenv(env_var) if env_var else None
22
+ if not key:
23
+ hint = env_var if env_var else "unknown provider"
24
+ raise RuntimeError(f"API key not set for '{provider}'. Add {hint} to your .env file.")
25
+ kwargs = {"api_key": key}
26
+ if provider in BASE_URLS:
27
+ kwargs["base_url"] = BASE_URLS[provider]
28
+ return OpenAI(**kwargs)