forgecodecli 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.
File without changes
forgecodecli/agent.py ADDED
@@ -0,0 +1,144 @@
1
+ # from dotenv import load_dotenv
2
+ from openai import OpenAI
3
+ # import os
4
+ import json
5
+ from json import JSONDecoder
6
+ from openai import RateLimitError
7
+ from forgecodecli.config import load_config
8
+ from forgecodecli.secrets import load_api_key
9
+
10
+ # Load env
11
+ # load_dotenv()
12
+
13
+ def get_client():
14
+ config = load_config()
15
+ api_key = load_api_key()
16
+
17
+ if not config:
18
+ raise RuntimeError(
19
+ "ForgeCodeCLI is not set up. Run `forgecodecli init`."
20
+ )
21
+
22
+ if not api_key:
23
+ raise RuntimeError(
24
+ "API key not found. Run `forgecodecli init` again."
25
+ )
26
+
27
+ provider = config.get("provider")
28
+
29
+ if provider == "gemini":
30
+ return OpenAI(
31
+ api_key=api_key,
32
+ base_url="https://generativelanguage.googleapis.com/v1beta/openai/"
33
+ )
34
+
35
+ raise RuntimeError(f"Unsupported provider: {provider}")
36
+
37
+
38
+ # client = OpenAI(
39
+ # api_key=os.getenv("GEMINI_API_KEY"),
40
+ # base_url="https://generativelanguage.googleapis.com/v1beta/openai/"
41
+ # )
42
+
43
+ SYSTEM_PROMPT = """
44
+ You are an agent that decides what action to take.
45
+
46
+ CRITICAL RULES:
47
+ 1. Each user request requires AT MOST TWO action that makes a change (write_file, create_dir, read_file, or list_files)
48
+ 2. After you have completed all required actions
49
+ (up to the allowed limit), respond with "answer"
50
+ 3. Do NOT take multiple write_file or create_dir actions
51
+ 4. Do NOT repeat actions
52
+
53
+ Actions available:
54
+ - "read_file": read a file
55
+ - "list_files": list directory contents
56
+ - "write_file": write/create a file
57
+ - "create_dir": create a directory
58
+ - "answer": respond to the user (use this after task is complete)
59
+
60
+ RESPONSE FORMAT - You MUST respond ONLY with valid JSON, nothing else:
61
+ {
62
+ "action": "answer",
63
+ "args": {
64
+ "text": "Your message here"
65
+ }
66
+ }
67
+
68
+ Examples:
69
+ - User: "create file.py with print('hello')" → write_file → ✅ appears → immediately return answer
70
+ - User: "read file.py" → read_file → content appears → immediately return answer
71
+ - User: "what's in src?" → list_files → files appear → immediately return answer
72
+
73
+
74
+ """
75
+
76
+ def think(messages: list[dict]) -> dict:
77
+ config = load_config()
78
+ model = config.get("model", "gemini-2.5-flash")
79
+ try:
80
+ client = get_client()
81
+ response = client.chat.completions.create(
82
+ model=model,
83
+ messages=[{"role": "system", "content": SYSTEM_PROMPT}] + messages
84
+ )
85
+ except RateLimitError as e:
86
+ return {
87
+ "action": "answer",
88
+ "args": {
89
+ "text": "⚠️ Rate limit hit. Please wait a few seconds and try again."
90
+ }
91
+ }
92
+ except Exception as e:
93
+ return {
94
+ "action": "answer",
95
+ "args": {
96
+ "text": f"❌ LLM error: {str(e)}"
97
+ }
98
+ }
99
+
100
+
101
+ content = response.choices[0].message.content
102
+
103
+ # Handle empty response
104
+ if content is None or not content.strip():
105
+ return {
106
+ "action": "answer",
107
+ "args": {
108
+ "text": "Task completed successfully!"
109
+ }
110
+ }
111
+
112
+ cleaned = content.strip()
113
+
114
+ # Robust JSON extraction
115
+ decoder = JSONDecoder()
116
+
117
+ # Try to find JSON object in the content
118
+ idx = cleaned.find("{")
119
+ if idx == -1:
120
+ return {
121
+ "action": "answer",
122
+ "args": {
123
+ "text": cleaned
124
+ }
125
+ }
126
+
127
+ # Extract from first { onwards
128
+ cleaned = cleaned[idx:]
129
+
130
+ # Try to decode JSON, handling partial/malformed content
131
+ try:
132
+ obj, _ = decoder.raw_decode(cleaned)
133
+ return obj
134
+ except json.JSONDecodeError:
135
+ # If it fails, try to find the end of a valid JSON object
136
+ # by trying progressively shorter strings from the end
137
+ for end_pos in range(len(cleaned), idx, -1):
138
+ try:
139
+ obj, _ = decoder.raw_decode(cleaned[:end_pos])
140
+ return obj
141
+ except json.JSONDecodeError:
142
+ continue
143
+
144
+ raise ValueError(f"Could not parse JSON from LLM output: {cleaned[:100]}...")
forgecodecli/cli.py ADDED
@@ -0,0 +1,210 @@
1
+ import typer
2
+ from forgecodecli.agent import think
3
+ from forgecodecli.tools import read_file, list_files, write_file, create_dir
4
+ import getpass
5
+
6
+ from forgecodecli.secrets import save_api_key, delete_api_key
7
+ from forgecodecli.config import save_config, config_exists, delete_config
8
+
9
+ app = typer.Typer()
10
+
11
+ import os
12
+
13
+ def show_logo():
14
+ cwd = os.getcwd()
15
+
16
+ print(f"""
17
+ ███████╗ ██████╗ ██████╗ ██████╗ ███████╗
18
+ ██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔════╝
19
+ █████╗ ██║ ██║██████╔╝██║ ███╗█████╗
20
+ ██╔══╝ ██║ ██║██╔══██╗██║ ██║██╔══╝
21
+ ██║ ╚██████╔╝██║ ██║╚██████╔╝███████╗
22
+ ╚═╝ ╚═════╝ ╚═╝ ╚═╝ ╚═════╝ ╚══════╝
23
+
24
+ ForgeCode CLI • Agentic File Assistant
25
+ Safe • Deterministic • File-aware
26
+
27
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
28
+ Agent Mode : Code Agent
29
+ Model : Gemini 2.5 Flash
30
+ Workspace : {cwd}
31
+ ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
32
+
33
+ Type natural language commands to manage files.
34
+ (type 'quit' or Ctrl+C to exit)\n
35
+ """)
36
+
37
+ @app.command()
38
+ def init() :
39
+ """
40
+ Initialize ForgeCode CLI configuration
41
+ """
42
+ if config_exists():
43
+ typer.echo("ForgeCodeCLI is already set up.")
44
+ typer.echo("Use `forgecodecli reset` to reconfigure.")
45
+ return
46
+
47
+ typer.echo("Welcome to ForgeCodeCLI ✨")
48
+ typer.echo("Let's set things up.\n")
49
+
50
+ typer.echo("Select LLM provider:")
51
+ typer.echo(" 1) Gemini")
52
+ typer.echo(" 2) Exit")
53
+
54
+ choice = typer.prompt(">")
55
+ if choice != "1":
56
+ typer.echo("Exiting setup.")
57
+ return
58
+
59
+ api_key = getpass.getpass("Enter your Gemini API Key: ")
60
+ if not api_key.strip():
61
+ typer.echo("API Key cannot be empty. Exiting setup.")
62
+ return
63
+ save_api_key(api_key)
64
+
65
+ config = {
66
+ "provider": "gemini",
67
+ "model": "gemini-2.5-flash"
68
+ }
69
+
70
+ save_config(config)
71
+
72
+ typer.echo("\n✓ API key saved securely")
73
+ typer.echo("✓ Provider set to Gemini")
74
+ typer.echo("✓ Model set to gemini-2.5-flash")
75
+ typer.echo("\nSetup complete.")
76
+ typer.echo("Run `forgecodecli` to start.")
77
+
78
+ @app.command()
79
+ def reset():
80
+ """
81
+ Reset ForgeCodeCLI configuration and API key
82
+ """
83
+ if not config_exists():
84
+ typer.echo("ForgeCodeCLI is not set up.")
85
+ return
86
+
87
+ typer.echo(
88
+ "This will remove your ForgeCodeCLI configuration and API key."
89
+ )
90
+ confirm = typer.prompt("Are you sure? (y/N)", default="n")
91
+
92
+ if confirm.lower() != "y":
93
+ typer.echo("Reset cancelled.")
94
+ return
95
+
96
+ try:
97
+ delete_api_key()
98
+ typer.echo("✓ API key removed")
99
+ except Exception:
100
+ typer.echo("⚠️ No API key found")
101
+
102
+ delete_config()
103
+ typer.echo("✓ Configuration deleted")
104
+
105
+ typer.echo("\nForgeCodeCLI has been reset.")
106
+ typer.echo("Run `forgecodecli init` to set it up again.")
107
+
108
+
109
+ def describe_action(action: str, args: dict):
110
+ if action == "read_file":
111
+ print(f"📂 Reading file: {args.get('path')}")
112
+ elif action == "list_files":
113
+ print(f"📄 Listing files in: {args.get('path', '.')}")
114
+ elif action == "create_dir":
115
+ print(f"📁 Creating directory: {args.get('path')}")
116
+ elif action == "write_file":
117
+ print(f"✍️ Writing file: {args.get('path')}")
118
+
119
+
120
+ @app.callback(invoke_without_command=True)
121
+ def run(ctx: typer.Context):
122
+ """
123
+ ForgeCode CLI — agent with actions
124
+ """
125
+ if ctx.invoked_subcommand is not None:
126
+ return
127
+
128
+ if not config_exists():
129
+ typer.echo("ForgeCodeCLI is not set up yet.")
130
+ typer.echo("Run `forgecodecli init` first.")
131
+ return
132
+
133
+ # ===============================
134
+ # INTERACTIVE MODE
135
+ # ===============================
136
+ show_logo()
137
+ messages = []
138
+
139
+ try:
140
+ while True:
141
+ user_input = input("forgecode (agent) > ").strip()
142
+
143
+ if user_input.lower() in ("quit", "exit"):
144
+ print("Bye")
145
+ break
146
+
147
+ messages.append({"role": "user", "content": user_input})
148
+ # print("🤔 Planning actions...")
149
+ answered = False
150
+
151
+ for _ in range(5):
152
+ decision = think(messages)
153
+ action = decision.get("action")
154
+ args = decision.get("args", {})
155
+
156
+ if action == "read_file":
157
+ describe_action(action, args)
158
+ result = read_file(args.get("path"))
159
+ print(result)
160
+ messages.append({"role": "assistant", "content": result})
161
+
162
+ elif action == "list_files":
163
+ describe_action(action, args)
164
+ result = list_files(args.get("path", "."))
165
+ print(result)
166
+ messages.append({"role": "assistant", "content": result})
167
+
168
+ elif action == "create_dir":
169
+ describe_action(action, args)
170
+ result = create_dir(args.get("path"))
171
+ print(result)
172
+ messages.append({"role": "assistant", "content": result})
173
+
174
+ elif action == "write_file":
175
+ describe_action(action, args)
176
+ result = write_file(
177
+ args.get("path"),
178
+ args.get("content")
179
+ )
180
+ print(result)
181
+ messages.append({"role": "assistant", "content": result})
182
+
183
+ elif action == "answer":
184
+ print(args.get("text", ""))
185
+ answered = True
186
+ # Keep only last 10 messages to avoid context overflow
187
+ if len(messages) > 20:
188
+ messages = messages[-20:]
189
+ break
190
+
191
+ if not answered:
192
+ print("⚠️ I couldn't complete this request.")
193
+ print("✅ Done")
194
+ # Keep only last 10 messages to avoid context overflow
195
+ if len(messages) > 20:
196
+ messages = messages[-20:]
197
+
198
+ except KeyboardInterrupt:
199
+ print("\nBye")
200
+
201
+ return
202
+
203
+
204
+
205
+ def main():
206
+ app()
207
+
208
+
209
+ if __name__ == "__main__":
210
+ main()
forgecodecli/config.py ADDED
@@ -0,0 +1,27 @@
1
+ from pathlib import Path
2
+ import json
3
+
4
+
5
+ CONFIG_DIR = Path.home() / ".forgecodecli"
6
+ CONFIG_FILE = CONFIG_DIR / "config.json"
7
+
8
+ def config_exists()-> bool:
9
+ return CONFIG_FILE.exists()
10
+
11
+ def load_config()-> dict:
12
+ if not config_exists():
13
+ return {}
14
+
15
+ with open(CONFIG_FILE, "r") as f:
16
+ return json.load(f)
17
+
18
+
19
+ def save_config(config: dict):
20
+ CONFIG_DIR.mkdir(exist_ok=True)
21
+ with open(CONFIG_FILE, "w") as f:
22
+ json.dump(config, f, indent=2)
23
+
24
+ def delete_config():
25
+ if config_exists():
26
+ CONFIG_FILE.unlink()
27
+
@@ -0,0 +1,13 @@
1
+ import keyring
2
+
3
+ SERVICE_NAME = "forgecodecli"
4
+
5
+ def save_api_key(api_key: str):
6
+ keyring.set_password(SERVICE_NAME, "api_key", api_key)
7
+
8
+ def load_api_key() -> str | None:
9
+ return keyring.get_password(SERVICE_NAME, "api_key")
10
+
11
+ def delete_api_key():
12
+ keyring.delete_password(SERVICE_NAME, "api_key")
13
+
forgecodecli/tools.py ADDED
@@ -0,0 +1,74 @@
1
+ from pathlib import Path
2
+ import os
3
+
4
+ def is_safe_path(path: str) -> bool:
5
+ if not path:
6
+ return False
7
+ if os.path.isabs(path):
8
+ return False
9
+ if ".." in path:
10
+ return False
11
+ return True
12
+
13
+
14
+ def read_file(path: str) -> str:
15
+ """Read a file and return its contents"""
16
+ file_path = Path(path)
17
+
18
+ if not file_path.exists():
19
+ return f"Error: File '{path}' does not exist."
20
+
21
+ return file_path.read_text()
22
+
23
+
24
+ def list_files(path:str = ".")-> str:
25
+ if not is_safe_path(path):
26
+ return "Error: Unsafe path detected."
27
+
28
+ full_path = os.path.join(os.getcwd(), path)
29
+
30
+ if not os.path.exists(full_path):
31
+ return "❌ Path does not exist."
32
+
33
+ if not os.path.isdir(full_path):
34
+ return "❌ Path is not a directory."
35
+
36
+ try:
37
+ items = os.listdir(full_path)
38
+ if not items:
39
+ return "📂 Directory is empty."
40
+
41
+ return "\n".join(sorted(items))
42
+ except Exception as e:
43
+ return f"❌ Error reading directory: {str(e)}"
44
+
45
+ def write_file(path: str, content: str) -> str:
46
+ if not is_safe_path(path):
47
+ return "❌ Invalid path."
48
+
49
+ full_path = os.path.join(os.getcwd(), path)
50
+
51
+ # Prevent overwriting existing files (for now)
52
+ if os.path.exists(full_path):
53
+ return "❌ File already exists. Overwrite not allowed."
54
+
55
+ parent = os.path.dirname(full_path)
56
+ if parent and not os.path.exists(parent):
57
+ return "❌ Parent directory does not exist."
58
+
59
+ try:
60
+ with open(full_path, "w", encoding="utf-8") as f:
61
+ f.write(content)
62
+ return f"✅ File written: {path}"
63
+ except Exception:
64
+ return "❌ Failed to write file."
65
+
66
+ def create_dir(path: str)-> str:
67
+ if not is_safe_path(path):
68
+ return "❌ Invalid path."
69
+ full_path = os.path.join(os.getcwd(), path)
70
+ try:
71
+ os.makedirs(full_path, exist_ok=True)
72
+ return f"✅ Directory created: {path}"
73
+ except Exception:
74
+ return "❌ Failed to create directory."
@@ -0,0 +1,127 @@
1
+ Metadata-Version: 2.4
2
+ Name: forgecodecli
3
+ Version: 0.1.0
4
+ Summary: A minimal agentic CLI for forging code
5
+ Author: Sudhanshu
6
+ License: MIT
7
+ Requires-Python: >=3.9
8
+ Description-Content-Type: text/markdown
9
+ Requires-Dist: typer[all]>=0.9.0
10
+ Requires-Dist: openai>=1.0.0
11
+ Requires-Dist: keyring>=24.0.0
12
+ Provides-Extra: dev
13
+ Requires-Dist: pytest>=7.0.0; extra == "dev"
14
+ Requires-Dist: black>=23.0.0; extra == "dev"
15
+ Requires-Dist: ruff>=0.1.0; extra == "dev"
16
+
17
+ # ForgeCodeCLI
18
+
19
+ An agentic, file-aware command-line tool that lets you manage and modify your codebase using natural language — powered by LLMs.
20
+
21
+ It acts as a safe, deterministic AI agent that can read files, create directories, and write code only through explicit tools, not raw hallucination.
22
+
23
+ ## Features
24
+
25
+ - Agentic workflow (LLM decides actions, CLI executes them)
26
+ - File-aware (read, list, create, write files & directories)
27
+ - Secure API key storage (no env vars required after setup)
28
+ - Deterministic and rule-based execution
29
+ - Interactive CLI experience
30
+ - Built to support multiple LLM providers (Gemini first)
31
+
32
+ ## Installation
33
+
34
+ Requires Python 3.9+
35
+
36
+ ```bash
37
+ pip install forgecodecli
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ### Initialize (one-time setup)
43
+
44
+ ```bash
45
+ forgecodecli init
46
+ ```
47
+
48
+ You will be prompted to:
49
+ - Select an LLM provider
50
+ - Enter your API key (stored securely)
51
+
52
+ ### Start the agent
53
+
54
+ ```bash
55
+ forgecodecli
56
+ ```
57
+
58
+ You are now in interactive agent mode. Example commands:
59
+
60
+ ```
61
+ create a folder src/app and add a main.py file that prints hello
62
+ read the README.md file
63
+ list all files in the src directory
64
+ quit
65
+ ```
66
+
67
+ Or press `Ctrl + C` to exit.
68
+
69
+ ## Reset Configuration
70
+
71
+ To remove all configuration and API keys:
72
+
73
+ ```bash
74
+ forgecodecli reset
75
+ ```
76
+
77
+ ## Security
78
+
79
+ - API keys are stored using the system keyring
80
+ - No API keys are written to config files or environment variables
81
+ - Config files contain only non-sensitive metadata
82
+
83
+ ## How It Works
84
+
85
+ 1. You enter a natural language command
86
+ 2. The LLM decides the next valid action
87
+ 3. ForgeCodeCLI executes the action safely
88
+ 4. The agent responds with the result
89
+
90
+ The agent is strictly limited to predefined tools, ensuring predictable and safe behavior.
91
+
92
+ ## Supported Actions
93
+
94
+ - `read_file`
95
+ - `list_files`
96
+ - `create_dir`
97
+ - `write_file`
98
+
99
+ No action outside these tools is permitted.
100
+
101
+ ## Status
102
+
103
+ This project is in active development.
104
+
105
+ **Current version supports:**
106
+ - Gemini LLM
107
+ - Interactive agent mode
108
+
109
+ **Planned features:**
110
+ - Multiple LLM providers
111
+ - Model switching
112
+ - Streaming responses
113
+ - Session memory
114
+
115
+ ## License
116
+
117
+ MIT License
118
+
119
+ ## Author
120
+
121
+ Built by Sudhanshu
122
+
123
+
124
+
125
+
126
+
127
+
@@ -0,0 +1,11 @@
1
+ forgecodecli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ forgecodecli/agent.py,sha256=ta-Ab8ORFzEQ0pbnCjf7CR6g4itBzZtjD6XMmay5Vgg,4124
3
+ forgecodecli/cli.py,sha256=h1ip6kRoDLwco5558vYfyCEp8XMQDNvTVt8cqBRhuJA,7116
4
+ forgecodecli/config.py,sha256=VFl21RTu0aTkYS3ConcgLoHD7LuV890d9LoXsAaYs5M,595
5
+ forgecodecli/secrets.py,sha256=ZEaHYF9TkibST0IwEBHZbbazaLWJZR04n4FyGx9n1gU,328
6
+ forgecodecli/tools.py,sha256=f6_bsblRpbYerHussznXNdnF93j3G6ppYWCHUgveoAI,2096
7
+ forgecodecli-0.1.0.dist-info/METADATA,sha256=4w3PTAFjS7q-n8FeJ16PoMutA_-YxMqZ2WyIAlOpKAE,2677
8
+ forgecodecli-0.1.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
9
+ forgecodecli-0.1.0.dist-info/entry_points.txt,sha256=KQDckJoVfVJtVxu3icUI99k-gpbIn_tihUG_ZwU4BAw,55
10
+ forgecodecli-0.1.0.dist-info/top_level.txt,sha256=OJLRIfDbRvaNKg55kSvPDagGlTxajt3m1jnI3X5x204,13
11
+ forgecodecli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (80.9.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ forgecodecli = forgecodecli.cli:main
@@ -0,0 +1 @@
1
+ forgecodecli