bob-dev 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.
__init__.py ADDED
File without changes
bob_dev/__init__.py ADDED
File without changes
bob_dev/cli.py ADDED
@@ -0,0 +1,293 @@
1
+ """cli.py
2
+
3
+ Entry point for the BOB Dev CLI tool.
4
+
5
+ Workflow
6
+ --------
7
+ 1. Fetch a Jira task by ID (title, description, fix versions).
8
+ 2. Read the project's Markdown documentation and build an LLM context string.
9
+ 3. Ask an LLM (GROK or OpenAI) to convert the acceptance criteria into a
10
+ Claude Code prompt, enriched with project context and framework info.
11
+ 4. Analyse the generated prompt for ambiguities and security concerns.
12
+ 5. Pass the final prompt to the Claude Code CLI for implementation.
13
+
14
+ Usage
15
+ -----
16
+ bob-dev --task_id PROJ-123 --path /path/to/repo
17
+ bob-dev --configure
18
+
19
+ Environment variables (.env next to this file or exported):
20
+ GROK_API_KEY – xAI / GROK secret key
21
+ OPENAI_API_KEY – OpenAI secret key
22
+ AGENT – "GROK" (default) or "OPENAI"
23
+ JIRA_URL – https://your-org.atlassian.net
24
+ JIRA_EMAIL – Atlassian account e-mail
25
+ JIRA_API_TOKEN – Atlassian API token
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import os
31
+ import sys
32
+ import argparse
33
+ import asyncio
34
+ from pathlib import Path
35
+
36
+ from InquirerPy import inquirer
37
+ from dotenv import load_dotenv
38
+
39
+ from .helpers.terminal import (
40
+ BOLD,
41
+ RESET,
42
+ print_error,
43
+ print_info,
44
+ print_step,
45
+ print_success,
46
+ print_warn,
47
+ run_subprocess,
48
+ run_with_spinner,
49
+ )
50
+ from .helpers.jira_helper import get_jira_task
51
+ from .helpers.llm_helper import analyse_prompt, llm_model, prompt_claude_code
52
+ from .helpers.project_helper import build_md_context, identify_framework
53
+ from .helpers.config_helper import check_configuration, update_env_file
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # Module-level configuration
57
+ # ---------------------------------------------------------------------------
58
+
59
+ SCRIPT_DIR = Path(__file__).resolve().parent
60
+ load_dotenv(SCRIPT_DIR / ".env")
61
+
62
+ OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", "")
63
+ GROK_API_KEY = os.environ.get("GROK_API_KEY", "")
64
+ AGENT = os.environ.get("AGENT", "GROK").upper() # "GROK" or "OPENAI"
65
+
66
+ JIRA_URL = os.environ.get("JIRA_URL", "")
67
+ JIRA_EMAIL = os.environ.get("JIRA_EMAIL", "")
68
+ JIRA_API_TOKEN = os.environ.get("JIRA_API_TOKEN", "")
69
+
70
+ REPO_BASE_PATH = Path("./") # Overridden by --path at runtime.
71
+ CLAUDE_CODE_CMD = "claude" # Must be on $PATH.
72
+
73
+
74
+ # ---------------------------------------------------------------------------
75
+ # Main entry point
76
+ # ---------------------------------------------------------------------------
77
+
78
+ def main() -> None:
79
+ """Parse CLI arguments and orchestrate the four-step workflow."""
80
+
81
+ parser = argparse.ArgumentParser(
82
+ prog="bob-dev",
83
+ description="BOB Dev – AI-assisted developer workflow tool.",
84
+ )
85
+ parser.add_argument(
86
+ "--task_id", type=str,
87
+ help="Jira task ID to process (e.g. PROJ-123).",
88
+ )
89
+ parser.add_argument(
90
+ "--path", type=str, default="./",
91
+ help="Path to the target project repository (default: current directory).",
92
+ )
93
+ parser.add_argument(
94
+ "--agent", type=str, choices=["GROK", "OPENAI"], default=AGENT,
95
+ help="LLM backend to use (default: GROK).",
96
+ )
97
+ parser.add_argument(
98
+ "--configure", action="store_true",
99
+ help="Run interactive setup to save API keys to .env.",
100
+ )
101
+ args = parser.parse_args()
102
+
103
+ # ── Interactive configuration wizard ────────────────────────────────────
104
+ if args.configure:
105
+ _run_configure()
106
+ sys.exit(0)
107
+
108
+ # ── Require task_id for normal workflow ──────────────────────────────────
109
+ if not args.task_id:
110
+ print_error("Jira task ID is required. Use --task_id PROJ-123.")
111
+ sys.exit(1)
112
+
113
+ task_id = args.task_id.strip().upper()
114
+ agent = args.agent.upper()
115
+
116
+ # ── Validate credentials before making any API calls ────────────────────
117
+ if not all([JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN]):
118
+ print_error("Jira credentials are not configured. Run `bob-dev --configure`.")
119
+ sys.exit(1)
120
+
121
+ if agent == "GROK" and not GROK_API_KEY:
122
+ print_error("GROK_API_KEY is not set. Run `bob-dev --configure`.")
123
+ sys.exit(1)
124
+
125
+ if agent == "OPENAI" and not OPENAI_API_KEY:
126
+ print_error("OPENAI_API_KEY is not set. Run `bob-dev --configure`.")
127
+ sys.exit(1)
128
+
129
+ # ── Resolve and validate the repository path ─────────────────────────────
130
+ global REPO_BASE_PATH
131
+ REPO_BASE_PATH = Path(args.path).resolve()
132
+
133
+ if not REPO_BASE_PATH.exists() or not REPO_BASE_PATH.is_dir():
134
+ print_error(f"Invalid project path: {REPO_BASE_PATH}")
135
+ sys.exit(1)
136
+
137
+ print_info(f"Project path : {REPO_BASE_PATH}")
138
+ print_info(f"Jira task ID : {task_id}")
139
+ print_info(f"LLM backend : {agent} ({llm_model(agent)})")
140
+ print()
141
+
142
+ # ── Step 1 – Fetch Jira task ─────────────────────────────────────────────
143
+ print_step("[1/4]", f"Fetching Jira task {task_id} …")
144
+
145
+ task = asyncio.run(run_with_spinner(
146
+ get_jira_task,
147
+ task_id, JIRA_URL, JIRA_EMAIL, JIRA_API_TOKEN,
148
+ label="Fetching Jira task",
149
+ ))
150
+
151
+ print_success(f"Title : {task['title']}")
152
+ print_success(f"Fix versions : {', '.join(task['fix_versions']) or 'N/A'}")
153
+ print()
154
+
155
+ acceptance_criteria = task["description"]
156
+ if not acceptance_criteria.strip():
157
+ print_error("The Jira task has no description / acceptance criteria.")
158
+ sys.exit(1)
159
+
160
+ # ── Step 2 – Read project docs & generate prompt ─────────────────────────
161
+ print_step("[2/4]", f"Generating Claude Code prompt via {agent} ({llm_model(agent)}) …")
162
+
163
+ # Collect Markdown context (blocking I/O) inside the spinner thread.
164
+ md_context = asyncio.run(run_with_spinner(
165
+ build_md_context, REPO_BASE_PATH,
166
+ label="Reading project docs",
167
+ ))
168
+
169
+ # Framework detection is fast – no spinner needed.
170
+ try:
171
+ framework = identify_framework(md_context)
172
+ print_success(f"Detected framework : {framework}")
173
+ except ValueError as exc:
174
+ print_warn(str(exc))
175
+ framework = "the project"
176
+
177
+ prompt_md = asyncio.run(run_with_spinner(
178
+ prompt_claude_code,
179
+ acceptance_criteria, md_context, framework,
180
+ agent, GROK_API_KEY, OPENAI_API_KEY,
181
+ label="Generating prompt",
182
+ task_meta=task,
183
+ ))
184
+
185
+ if not prompt_md.strip():
186
+ print_error("Failed to generate a prompt for Claude Code.")
187
+ sys.exit(1)
188
+
189
+ print_success("Prompt generated.")
190
+ print()
191
+
192
+ # ── Step 3 – Analyse the prompt ──────────────────────────────────────────
193
+ print_step("[3/4]", "Analysing the prompt for issues …")
194
+
195
+ analysis = asyncio.run(run_with_spinner(
196
+ analyse_prompt,
197
+ prompt_md, acceptance_criteria,
198
+ agent, GROK_API_KEY, OPENAI_API_KEY,
199
+ label="Analysing prompt",
200
+ ))
201
+
202
+ print(f"\n{BOLD}── Prompt Analysis {'─' * 50}{RESET}")
203
+ print(analysis)
204
+ print("─" * 68 + "\n")
205
+
206
+ # ── Confirm before handing off to Claude Code ────────────────────────────
207
+ answer = input("Proceed and send prompt to Claude Code? [y/N] ").strip().lower()
208
+ if answer != "y":
209
+ prompt_file = SCRIPT_DIR / f"claude_prompt-{task_id}.md"
210
+ prompt_file.write_text(prompt_md, encoding="utf-8")
211
+ print_info(f"Aborted. Prompt saved to {prompt_file}")
212
+ sys.exit(0)
213
+
214
+ # ── Step 4 – Pass prompt to Claude Code ──────────────────────────────────
215
+ print_step("[4/4]", "Passing prompt to Claude Code …")
216
+ print()
217
+ asyncio.run(_pass_to_claude_code(prompt_md, task_id))
218
+
219
+
220
+ # ---------------------------------------------------------------------------
221
+ # Internal helpers
222
+ # ---------------------------------------------------------------------------
223
+
224
+ async def _pass_to_claude_code(prompt_md: str, task_id: str) -> None:
225
+ """Write the prompt to a temp file, then run Claude Code non-interactively."""
226
+
227
+ # Persist the prompt so the user can review it regardless of outcome.
228
+ prompt_file = SCRIPT_DIR / "tmp" / f"claude_prompt_{task_id}.md"
229
+ prompt_file.parent.mkdir(parents=True, exist_ok=True)
230
+ prompt_file.write_text(prompt_md, encoding="utf-8")
231
+ print_info(f"Prompt saved to: {prompt_file}")
232
+
233
+ cmd = [CLAUDE_CODE_CMD, "--dangerously-skip-permissions", "--print", prompt_md]
234
+ print_info(f"Running: {' '.join(cmd[:2])} <prompt>")
235
+ print()
236
+
237
+ returncode, _, _ = await run_subprocess(cmd, REPO_BASE_PATH)
238
+
239
+ if returncode != 0:
240
+ print_warn(f"Claude Code exited with code {returncode}")
241
+
242
+
243
+ def _run_configure() -> None:
244
+ """Interactive wizard to write API keys and Jira credentials to .env."""
245
+ env_path = SCRIPT_DIR / ".env"
246
+ print_step("[CONFIGURE]", "Running initial configuration …")
247
+
248
+ # Choose the LLM backend.
249
+ system_choice = inquirer.select(
250
+ message="Select the default LLM backend:",
251
+ choices=["GROK", "OPENAI"],
252
+ ).execute()
253
+
254
+ # Collect the appropriate API key.
255
+ print(f"Enter your {system_choice} API key:")
256
+ api_key = input("> ").strip()
257
+ env_key = "GROK_API_KEY" if system_choice == "GROK" else "OPENAI_API_KEY"
258
+ update_env_file(env_key, api_key, env_path)
259
+ print_success(f"{system_choice} API key saved.")
260
+
261
+ # Jira credentials.
262
+ print("\nJira configuration:")
263
+ jira_url = input("JIRA_URL (e.g. https://your-org.atlassian.net): ").strip()
264
+ jira_email = input("JIRA_EMAIL (your Atlassian account e-mail) : ").strip()
265
+ jira_token = input("JIRA_API_TOKEN (Atlassian API token) : ").strip()
266
+
267
+ for key, val in [
268
+ ("JIRA_URL", jira_url),
269
+ ("JIRA_EMAIL", jira_email),
270
+ ("JIRA_API_TOKEN", jira_token),
271
+ ]:
272
+ update_env_file(key, val, env_path)
273
+
274
+ print_success("Jira configuration saved.")
275
+
276
+ # Claude Code API key.
277
+ print("\nClaude Code API key:")
278
+ claude_key = input("CLAUDE_API_KEY: ").strip()
279
+ update_env_file("CLAUDE_API_KEY", claude_key, env_path)
280
+ print_success("Claude Code configuration saved.")
281
+
282
+ # Optional: verify all keys immediately.
283
+ answer = input("\nVerify all keys now? [y/N] ").strip().lower()
284
+ if answer == "y":
285
+ check_configuration(
286
+ agent = os.environ.get("AGENT", "GROK").upper(),
287
+ grok_api_key = os.environ.get("GROK_API_KEY", ""),
288
+ openai_api_key = os.environ.get("OPENAI_API_KEY", ""),
289
+ jira_url = jira_url,
290
+ jira_email = jira_email,
291
+ jira_api_token = jira_token,
292
+ claude_cmd = CLAUDE_CODE_CMD,
293
+ )
File without changes
@@ -0,0 +1,28 @@
1
+ AVAILABLE_FRAMEWORKS = [
2
+ "Django REST Framework",
3
+ "Flask",
4
+ "FastAPI",
5
+ "Express.js",
6
+ "Spring Boot",
7
+ "Ruby on Rails",
8
+ "Laravel",
9
+ "ASP.NET Core",
10
+ "Symfony",
11
+ "CakePHP",
12
+ "CodeIgniter",
13
+ "Angular",
14
+ "React",
15
+ "Vue.js",
16
+ "Svelte",
17
+ "Next.js",
18
+ "Nuxt.js",
19
+ "Flutter",
20
+ "React Native",
21
+ "Ionic",
22
+ "Xamarin",
23
+ "Electron",
24
+ "NestJS",
25
+ "AdonisJS",
26
+ "Hadoop",
27
+ "Spark",
28
+ ]
@@ -0,0 +1 @@
1
+ # helpers package – utility modules for bob_dev
@@ -0,0 +1,107 @@
1
+ """config_helper.py
2
+
3
+ Configuration utilities for bob_dev:
4
+ - Write / update key-value pairs in the .env file.
5
+ - Validate all external service credentials and print coloured results.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import os
11
+ import subprocess
12
+ from pathlib import Path
13
+
14
+ from dotenv import load_dotenv
15
+
16
+ from .terminal import print_error, print_success, print_info
17
+
18
+
19
+ # ---------------------------------------------------------------------------
20
+ # .env management
21
+ # ---------------------------------------------------------------------------
22
+
23
+ def update_env_file(key: str, value: str, env_path: Path) -> None:
24
+ """Write or update *key=value* in *env_path* and apply it to os.environ.
25
+
26
+ If the key already exists in the file it is updated in place; otherwise
27
+ a new line is appended. The updated file is also re-loaded via dotenv.
28
+ """
29
+ lines: list[str] = []
30
+ if env_path.exists():
31
+ lines = env_path.read_text(encoding="utf-8").splitlines()
32
+
33
+ updated = False
34
+ for i, line in enumerate(lines):
35
+ if line.startswith(f"{key}="):
36
+ lines[i] = f"{key}={value}"
37
+ updated = True
38
+ break
39
+
40
+ if not updated:
41
+ lines.append(f"{key}={value}")
42
+
43
+ env_path.write_text("\n".join(lines) + "\n", encoding="utf-8")
44
+ os.environ[key] = value
45
+ load_dotenv(env_path)
46
+
47
+
48
+ # ---------------------------------------------------------------------------
49
+ # Configuration verification
50
+ # ---------------------------------------------------------------------------
51
+
52
+ def check_configuration(
53
+ agent: str,
54
+ grok_api_key: str,
55
+ openai_api_key: str,
56
+ jira_url: str,
57
+ jira_email: str,
58
+ jira_api_token: str,
59
+ claude_cmd: str,
60
+ ) -> None:
61
+ """Validate all API keys and the Claude Code CLI, printing coloured results.
62
+
63
+ Each check is independent: a failure in one does not stop the others.
64
+ """
65
+ # ── LLM API key ───────────────────────────────────────────────────────
66
+ print_info(f"Checking {agent} API key …")
67
+ try:
68
+ from .llm_helper import build_llm_client, llm_model
69
+
70
+ client = build_llm_client(agent, grok_api_key, openai_api_key)
71
+ model = llm_model(agent)
72
+ response = client.chat.completions.create(
73
+ model=model,
74
+ messages=[{"role": "system", "content": "Say: API key OK"}],
75
+ temperature=0,
76
+ )
77
+ reply = response.choices[0].message.content or ""
78
+ print_success(f"{agent} API key working. Response: {reply}")
79
+ except Exception as exc:
80
+ print_error(f"Failed to connect to {agent} API: {exc}")
81
+
82
+ # ── Jira credentials ──────────────────────────────────────────────────
83
+ print_info("Checking Jira credentials …")
84
+ try:
85
+ from atlassian import Jira
86
+
87
+ jira = Jira(url=jira_url, username=jira_email, password=jira_api_token, cloud=True)
88
+ user = jira.myself()
89
+ print_success(f"Jira credentials OK. Authenticated as: {user.get('displayName')}")
90
+ except Exception as exc:
91
+ print_error(f"Failed to connect to Jira: {exc}")
92
+
93
+ # ── Claude Code CLI ───────────────────────────────────────────────────
94
+ print_info(f"Checking Claude Code CLI ({claude_cmd}) …")
95
+ try:
96
+ result = subprocess.run(
97
+ [claude_cmd, "--version"],
98
+ capture_output=True,
99
+ text=True,
100
+ timeout=10,
101
+ )
102
+ if result.returncode == 0:
103
+ print_success(f"Claude Code CLI OK. Version: {result.stdout.strip()}")
104
+ else:
105
+ print_error(f"Claude Code CLI error: {result.stderr.strip()}")
106
+ except Exception as exc:
107
+ print_error(f"Failed to run Claude Code CLI: {exc}")
@@ -0,0 +1,75 @@
1
+ """jira_helper.py
2
+
3
+ Jira integration utilities for bob_dev:
4
+ - Fetch a Jira issue and normalise its fields into a plain dict.
5
+ - Parse Atlassian Document Format (ADF) nodes into plain text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from atlassian import Jira
11
+
12
+
13
+ def get_jira_task(
14
+ task_id: str,
15
+ jira_url: str,
16
+ jira_email: str,
17
+ jira_token: str,
18
+ ) -> dict:
19
+ """Connect to Jira Cloud and return normalised fields for *task_id*.
20
+
21
+ Returns a dict with keys:
22
+ task_id (str)
23
+ title (str)
24
+ description (str – plain text extracted from ADF or raw string)
25
+ fix_versions (list[str])
26
+ """
27
+ jira = Jira(
28
+ url=jira_url,
29
+ username=jira_email,
30
+ password=jira_token,
31
+ cloud=True,
32
+ )
33
+
34
+ issue = jira.issue(task_id)
35
+ fields = issue.get("fields", {})
36
+
37
+ title: str = fields.get("summary", "")
38
+ fix_versions: list[str] = [v["name"] for v in fields.get("fixVersions", [])]
39
+
40
+ # The description field may be Atlassian Document Format (dict) or plain text.
41
+ raw_description = fields.get("description") or ""
42
+ description = (
43
+ _adf_to_text(raw_description)
44
+ if isinstance(raw_description, dict)
45
+ else str(raw_description)
46
+ )
47
+
48
+ return {
49
+ "task_id": task_id,
50
+ "title": title,
51
+ "description": description,
52
+ "fix_versions": fix_versions,
53
+ }
54
+
55
+
56
+ def _adf_to_text(node: dict, _depth: int = 0) -> str:
57
+ """Recursively extract plain text from an Atlassian Document Format node.
58
+
59
+ ADF is a nested JSON tree. This function walks the tree and joins
60
+ text leaves with appropriate line-break separators based on node type.
61
+ """
62
+ node_type = node.get("type", "")
63
+ text = node.get("text", "")
64
+ children = node.get("content", [])
65
+
66
+ parts: list[str] = []
67
+ if text:
68
+ parts.append(text)
69
+ for child in children:
70
+ parts.append(_adf_to_text(child, _depth + 1))
71
+
72
+ # Block-level nodes get a newline separator; inline nodes do not.
73
+ block_types = {"paragraph", "heading", "listItem", "bulletList", "orderedList"}
74
+ separator = "\n" if node_type in block_types else ""
75
+ return separator.join(parts)
@@ -0,0 +1,187 @@
1
+ """llm_helper.py
2
+
3
+ LLM integration for bob_dev:
4
+ - Build an OpenAI-compatible client for GROK or OpenAI backends.
5
+ - Return the appropriate model name for a given backend.
6
+ - Generate a Claude Code prompt from Jira acceptance criteria.
7
+ - Analyse a generated prompt for gaps and security concerns.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import textwrap
13
+
14
+ from openai import OpenAI
15
+
16
+
17
+ # ---------------------------------------------------------------------------
18
+ # Client & model helpers
19
+ # ---------------------------------------------------------------------------
20
+
21
+ def build_llm_client(agent: str, grok_api_key: str, openai_api_key: str) -> OpenAI:
22
+ """Return an OpenAI-compatible client for *agent* ("GROK" or "OPENAI").
23
+
24
+ Raises EnvironmentError if the required API key is missing.
25
+ """
26
+ if agent == "GROK":
27
+ if not grok_api_key:
28
+ raise EnvironmentError("GROK_API_KEY is not set in .env")
29
+ return OpenAI(api_key=grok_api_key, base_url="https://api.x.ai/v1")
30
+
31
+ # Default: OpenAI
32
+ if not openai_api_key:
33
+ raise EnvironmentError("OPENAI_API_KEY is not set in .env")
34
+ return OpenAI(api_key=openai_api_key)
35
+
36
+
37
+ def llm_model(agent: str) -> str:
38
+ """Return the model identifier for the given *agent* backend."""
39
+ return "grok-3" if agent == "GROK" else "gpt-4o"
40
+
41
+
42
+ # ---------------------------------------------------------------------------
43
+ # Prompt generation
44
+ # ---------------------------------------------------------------------------
45
+
46
+ def prompt_claude_code(
47
+ acceptance_criteria: str,
48
+ md_context: str,
49
+ project_framework: str,
50
+ agent: str,
51
+ grok_api_key: str,
52
+ openai_api_key: str,
53
+ task_meta: dict | None = None,
54
+ ) -> str:
55
+ """Ask the LLM to convert *acceptance_criteria* into a Claude Code prompt.
56
+
57
+ Parameters
58
+ ----------
59
+ acceptance_criteria:
60
+ Raw description text from the Jira task.
61
+ md_context:
62
+ Pre-built string of project Markdown files + summarised README.
63
+ project_framework:
64
+ Detected framework name (e.g. "Django REST Framework").
65
+ agent:
66
+ "GROK" or "OPENAI".
67
+ grok_api_key / openai_api_key:
68
+ API keys; the unused one may be an empty string.
69
+ task_meta:
70
+ Optional dict with 'task_id', 'title', 'fix_versions' for context.
71
+
72
+ Returns
73
+ -------
74
+ str
75
+ Markdown-formatted prompt ready to be fed to Claude Code.
76
+ """
77
+ client = build_llm_client(agent, grok_api_key, openai_api_key)
78
+ model = llm_model(agent)
79
+
80
+ # Build optional task-metadata block injected into the user message.
81
+ meta_block = ""
82
+ if task_meta:
83
+ versions = ", ".join(task_meta.get("fix_versions", [])) or "N/A"
84
+ meta_block = textwrap.dedent(f"""
85
+ ## Task Metadata
86
+ - **ID:** {task_meta.get('task_id', '')}
87
+ - **Title:** {task_meta.get('title', '')}
88
+ - **Fix Versions:** {versions}
89
+ """)
90
+
91
+ system_prompt = textwrap.dedent(f"""
92
+ You are a senior software engineer working on a {project_framework} project.
93
+ Your job is to convert a Jira acceptance criteria description into a precise,
94
+ actionable prompt for Claude Code – an AI coding assistant that implements
95
+ features directly in the codebase.
96
+
97
+ The prompt you produce must:
98
+ 1. Be formatted in **Markdown**.
99
+ 2. Have a clear "## Objective" section summarising what needs to be built.
100
+ 3. Have a "## Context" section referencing relevant {project_framework} apps
101
+ and models from the provided project docs.
102
+ 4. Have an "## Implementation Steps" section with numbered, concrete steps.
103
+ 5. Have a "## Test Scenarios" section with specific unit / integration tests
104
+ (use {project_framework} TestCase / DRF APITestCase conventions).
105
+ 6. Have an "## Acceptance Criteria" section restating the original criteria
106
+ as a dev-friendly checklist.
107
+ 7. Prevent N+1 queries and other common {project_framework} performance pitfalls.
108
+ 8. NOT assume information absent from the acceptance criteria or project docs.
109
+ 9. Be concise – avoid unnecessary prose.
110
+ 10. NOT invent API contracts or schemas that contradict the existing docs.
111
+ 11. NOT include any instructions about commits, PRs, or GitHub interactions.
112
+ """).strip()
113
+
114
+ user_message = textwrap.dedent(f"""
115
+ ## Project Documentation
116
+
117
+ {md_context}
118
+
119
+ ---
120
+
121
+ {meta_block}
122
+
123
+ ## Acceptance Criteria (from Jira)
124
+
125
+ {acceptance_criteria}
126
+
127
+ ---
128
+
129
+ Convert the acceptance criteria above into a Claude Code prompt.
130
+ """).strip()
131
+
132
+ response = client.chat.completions.create(
133
+ model=model,
134
+ messages=[
135
+ {"role": "system", "content": system_prompt},
136
+ {"role": "user", "content": user_message},
137
+ ],
138
+ temperature=0.3,
139
+ )
140
+
141
+ return response.choices[0].message.content or ""
142
+
143
+
144
+ # ---------------------------------------------------------------------------
145
+ # Prompt analysis
146
+ # ---------------------------------------------------------------------------
147
+
148
+ def analyse_prompt(
149
+ prompt_md: str,
150
+ acceptance_criteria: str,
151
+ agent: str,
152
+ grok_api_key: str,
153
+ openai_api_key: str,
154
+ ) -> str:
155
+ """Ask the LLM to review *prompt_md* for ambiguities and security concerns.
156
+
157
+ Returns a short bullet-point analysis string (max ~300 words).
158
+ """
159
+ client = build_llm_client(agent, grok_api_key, openai_api_key)
160
+ model = llm_model(agent)
161
+
162
+ system_prompt = textwrap.dedent("""
163
+ You are a senior tech lead reviewing a prompt for an AI coding assistant.
164
+ Identify:
165
+ - Ambiguities or missing information that could cause wrong implementation.
166
+ - Security concerns (missing auth checks, data exposure, injection risks).
167
+ - Whether the test scenarios adequately cover the acceptance criteria.
168
+ - Suggested improvements if needed.
169
+
170
+ Be concise. Use bullet points. Max 300 words.
171
+ """).strip()
172
+
173
+ user_message = (
174
+ f"## Original Acceptance Criteria\n\n{acceptance_criteria}\n\n"
175
+ f"## Generated Claude Code Prompt\n\n{prompt_md}"
176
+ )
177
+
178
+ response = client.chat.completions.create(
179
+ model=model,
180
+ messages=[
181
+ {"role": "system", "content": system_prompt},
182
+ {"role": "user", "content": user_message},
183
+ ],
184
+ temperature=0.2,
185
+ )
186
+
187
+ return response.choices[0].message.content or ""
@@ -0,0 +1,101 @@
1
+ """project_helper.py
2
+
3
+ Project context utilities for bob_dev:
4
+ - Collect Markdown files from the repository for LLM context.
5
+ - Summarise README.md content.
6
+ - Detect the primary framework used in the project.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+
13
+ from summa.summarizer import summarize
14
+
15
+ from ..constants.frameworks import AVAILABLE_FRAMEWORKS
16
+
17
+
18
+ # ---------------------------------------------------------------------------
19
+ # Markdown context collection
20
+ # ---------------------------------------------------------------------------
21
+
22
+ def collect_md_context(
23
+ root: Path,
24
+ max_files: int = 30,
25
+ max_chars: int = 40_000,
26
+ ) -> str:
27
+ """Walk *root* and return concatenated content of Markdown files.
28
+
29
+ Files are sorted alphabetically and capped at *max_files* / *max_chars*
30
+ to keep the LLM context window manageable.
31
+ """
32
+ md_files = sorted(root.rglob("*.md"))[:max_files]
33
+ parts: list[str] = []
34
+ total = 0
35
+
36
+ for md in md_files:
37
+ try:
38
+ text = md.read_text(encoding="utf-8", errors="ignore")
39
+ except OSError:
40
+ continue
41
+
42
+ relative = md.relative_to(root)
43
+ chunk = f"### {relative}\n\n{text}\n\n"
44
+
45
+ if total + len(chunk) > max_chars:
46
+ break
47
+
48
+ parts.append(chunk)
49
+ total += len(chunk)
50
+
51
+ return "".join(parts)
52
+
53
+
54
+ def read_readme(repo_path: Path) -> str:
55
+ """Return the content of README.md (case-insensitive) at *repo_path*.
56
+
57
+ Returns an empty string if no README file is found.
58
+ """
59
+ for name in ("README.md", "readme.md", "Readme.md"):
60
+ path = repo_path / name
61
+ if path.exists() and path.is_file():
62
+ try:
63
+ return path.read_text(encoding="utf-8", errors="ignore")
64
+ except OSError:
65
+ pass
66
+ return ""
67
+
68
+
69
+ def build_md_context(repo_path: Path) -> str:
70
+ """Build a combined context string for LLM consumption.
71
+
72
+ Combines a summarised README (up to 300 words) with the full
73
+ Markdown context collected from the repository.
74
+ """
75
+ readme_raw = read_readme(repo_path)
76
+ readme_summary = summarize(readme_raw, words=300) if readme_raw.strip() else ""
77
+ md_context = collect_md_context(repo_path)
78
+
79
+ return "# README:\n" + readme_summary + "\n\n# Context:\n" + md_context
80
+
81
+
82
+ # ---------------------------------------------------------------------------
83
+ # Framework detection
84
+ # ---------------------------------------------------------------------------
85
+
86
+ def identify_framework(combined_text: str) -> str:
87
+ """Detect the primary framework from *combined_text* (README + MD context).
88
+
89
+ Iterates over the known frameworks list and returns the first match.
90
+
91
+ Raises ValueError if no framework is detected.
92
+ """
93
+ lower = combined_text.lower()
94
+ for framework in AVAILABLE_FRAMEWORKS:
95
+ if framework.lower() in lower:
96
+ return framework
97
+
98
+ raise ValueError(
99
+ "Could not detect the project framework from the documentation. "
100
+ "Ensure your README.md or Markdown files mention the framework name."
101
+ )
@@ -0,0 +1,134 @@
1
+ """terminal.py
2
+
3
+ Terminal output utilities for bob_dev:
4
+ - ANSI color constants
5
+ - Typed print helpers (error = red, success = green, plain for everything else)
6
+ - Async spinner with ASCII desert-car animation
7
+ - Async subprocess runner with the same animation
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import asyncio
13
+ import time
14
+ from pathlib import Path
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # ANSI color codes
18
+ # ---------------------------------------------------------------------------
19
+
20
+ RED = "\033[91m"
21
+ GREEN = "\033[92m"
22
+ YELLOW = "\033[93m"
23
+ BOLD = "\033[1m"
24
+ RESET = "\033[0m"
25
+
26
+ # ---------------------------------------------------------------------------
27
+ # Print helpers
28
+ # ---------------------------------------------------------------------------
29
+
30
+ def print_error(msg: str) -> None:
31
+ """Print a red error line prefixed with [✗]."""
32
+ print(f"{RED}[✗] {msg}{RESET}")
33
+
34
+
35
+ def print_success(msg: str) -> None:
36
+ """Print a green success line prefixed with [✓]."""
37
+ print(f"{GREEN}[✓] {msg}{RESET}")
38
+
39
+
40
+ def print_info(msg: str) -> None:
41
+ """Print a plain informational line (no colour)."""
42
+ print(f"[i] {msg}")
43
+
44
+
45
+ def print_warn(msg: str) -> None:
46
+ """Print a plain warning line (no colour)."""
47
+ print(f"[!] {msg}")
48
+
49
+
50
+ def print_step(step: str, msg: str) -> None:
51
+ """Print a bold step label (e.g. '[1/4]') followed by a plain message."""
52
+ print(f"{BOLD}{step}{RESET} {msg}")
53
+
54
+
55
+ # ---------------------------------------------------------------------------
56
+ # ASCII desert-car animation helpers
57
+ # ---------------------------------------------------------------------------
58
+
59
+ # The landscape string is scrolled left one character per frame.
60
+ _DESERT = ". . ● . . o . . ● " * 2 # Repeated to ensure it's long enough for smooth scrolling
61
+ # Four frames of wheel animation for the car body.ƒ
62
+ _CAR_FRAMES = ["O", "O", "ᗧ", "ᗧ"]
63
+
64
+
65
+ def _render_car_frame(i: int, label: str, elapsed: float) -> str:
66
+ """Build the single-line spinner string for frame index *i*."""
67
+ offset = i % len(_DESERT)
68
+ landscape = (_DESERT[offset:] + _DESERT[:offset])[:6]
69
+ car = _CAR_FRAMES[i % len(_CAR_FRAMES)]
70
+ return f"\r {YELLOW}{car}{RESET} {landscape}(tokens) - {label}... ({elapsed:.1f}s)"
71
+
72
+
73
+ # ---------------------------------------------------------------------------
74
+ # Async spinner wrappers
75
+ # ---------------------------------------------------------------------------
76
+
77
+ async def run_with_spinner(func, *args, label: str = "Processing", **kwargs):
78
+ """Run a blocking *func* in a thread while showing the car animation.
79
+
80
+ Any extra *args* / *kwargs* are forwarded directly to *func*.
81
+ The keyword argument *label* controls the text shown next to the animation
82
+ and is consumed by this function (not forwarded).
83
+
84
+ Returns whatever *func* returns.
85
+ """
86
+ # Schedule the blocking call without awaiting it immediately.
87
+ task = asyncio.create_task(asyncio.to_thread(func, *args, **kwargs))
88
+
89
+ start_time = time.time()
90
+ i = 0
91
+ while not task.done():
92
+ elapsed = time.time() - start_time
93
+ print(_render_car_frame(i, label, elapsed), end="", flush=True)
94
+ i += 1
95
+ await asyncio.sleep(0.12)
96
+
97
+ # Clear the animation line.
98
+ print("\r" + " " * 70 + "\r", end="", flush=True)
99
+ return task.result()
100
+
101
+
102
+ async def run_subprocess(cmd: list[str], cwd: Path) -> tuple[int, str, str]:
103
+ """Run *cmd* as an async subprocess under *cwd* with the car animation.
104
+
105
+ Streams stdout and stderr to the terminal once the process finishes.
106
+
107
+ Returns:
108
+ (returncode, stdout, stderr)
109
+ """
110
+ proc = await asyncio.create_subprocess_exec(
111
+ *cmd,
112
+ cwd=str(cwd),
113
+ stdout=asyncio.subprocess.PIPE,
114
+ stderr=asyncio.subprocess.PIPE,
115
+ )
116
+
117
+ start_time = time.time()
118
+ i = 0
119
+ while proc.returncode is None:
120
+ elapsed = time.time() - start_time
121
+ print(_render_car_frame(i, "Running claude", elapsed), end="", flush=True)
122
+ i += 1
123
+ await asyncio.sleep(0.12)
124
+
125
+ # Clear the animation line.
126
+ print("\r" + " " * 70 + "\r", end="", flush=True)
127
+
128
+ stdout, stderr = await proc.communicate()
129
+ if stdout:
130
+ print(stdout.decode())
131
+ if stderr:
132
+ print(stderr.decode())
133
+
134
+ return proc.returncode, stdout.decode(), stderr.decode()
@@ -0,0 +1,103 @@
1
+ Metadata-Version: 2.4
2
+ Name: bob-dev
3
+ Version: 0.1.0
4
+ Summary: An AI developer to write code for you
5
+ Author-email: Samuel Pereira dos Santos <samuelsantosdev@gmail.com>
6
+ Requires-Python: >=3.14
7
+ Description-Content-Type: text/markdown
8
+ License-File: LICENSE
9
+ Provides-Extra: dev
10
+ Requires-Dist: pytest>=8.0; extra == "dev"
11
+ Requires-Dist: pytest-asyncio>=0.23; extra == "dev"
12
+ Dynamic: license-file
13
+
14
+ # BOB Dev
15
+
16
+ **BOB Dev** is an AI-powered developer workflow CLI that bridges a task requirements, your codebase, and Claude Code.
17
+
18
+ ![BOB-Dev Banner](https://github.com/samuelsantosdev/bob-dev/blob/main/assets/banner.png)
19
+
20
+ Given a Jira task ID it will:
21
+
22
+ 1. **Fetch** the task title, description, and fix versions from Jira.
23
+ 2. **Read** your project's Markdown documentation to build rich LLM context.
24
+ 3. **Generate** a precise Claude Code prompt (via GROK or OpenAI), including project-framework context, implementation steps, and test scenarios.
25
+ 4. **Analyse** the prompt for ambiguities and security concerns.
26
+ 5. **Execute** the prompt with the Claude Code CLI (optional – you can review first).
27
+
28
+ ---
29
+
30
+ ## Requirements
31
+
32
+ - Python 3.11+
33
+ - [Claude Code CLI](https://docs.anthropic.com/en/docs/claude-code) installed and available on `$PATH` as `claude`
34
+ - A Jira Cloud account with an [API token](https://id.atlassian.com/manage-profile/security/api-tokens)
35
+ - An [xAI / GROK](https://console.x.ai/) **or** [OpenAI](https://platform.openai.com/) API key
36
+
37
+ ---
38
+
39
+ ## Installation
40
+
41
+ ```bash
42
+ pip install bob-dev
43
+ ```
44
+
45
+ ---
46
+
47
+ ## Configuration
48
+
49
+ Run the interactive setup wizard the first time:
50
+
51
+ ```bash
52
+ bob-dev --configure
53
+ ```
54
+
55
+ This will prompt for your API keys, Jira credentials, and Claude Code API key.
56
+
57
+ ---
58
+
59
+ ## Usage
60
+
61
+ ```bash
62
+ bob-dev --task_id PROJ-123 --path /path/to/your/repo
63
+ ```
64
+
65
+ | Flag | Description |
66
+ |------|-------------|
67
+ | `--task_id` | Jira task ID to process (required for normal workflow) |
68
+ | `--path` | Path to the target repository (default: current directory) |
69
+ | `--agent` | LLM backend: `GROK` or `OPENAI` (default: value of `AGENT` in `.env`) |
70
+ | `--configure` | Run the interactive configuration wizard |
71
+
72
+ ---
73
+
74
+ ## Project structure
75
+
76
+ ```
77
+ src/bob_dev/
78
+ ├── cli.py # Entry point & main workflow orchestration
79
+ ├── helpers/
80
+ │ ├── terminal.py # ANSI colours, print helpers, spinner animation
81
+ │ ├── jira_helper.py # Jira API connection + ADF-to-text parsing
82
+ │ ├── llm_helper.py # LLM client, model selection, prompt generation
83
+ │ ├── project_helper.py # Markdown context collection + framework detection
84
+ │ └── config_helper.py # .env management + credential validation
85
+ └── constants/
86
+ └── frameworks.py # Known framework names used for auto-detection
87
+ ```
88
+
89
+ ---
90
+
91
+ ## Colour conventions
92
+
93
+ | Colour | Meaning |
94
+ |--------|---------|
95
+ | Red | Errors that stop execution |
96
+ | Green | Success messages |
97
+ | Plain | Informational / default output |
98
+
99
+ ---
100
+
101
+ ## License
102
+
103
+ MIT
@@ -0,0 +1,17 @@
1
+ __init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ bob_dev/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
3
+ bob_dev/cli.py,sha256=g8azCAJI0wTrlAhwQFeITpUuIGQIyU9MEK2uJGXOqNM,11093
4
+ bob_dev/constants/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ bob_dev/constants/frameworks.py,sha256=Zcp1lqAY4re4CjO-0DLEpU4mD71UWCaxCMZjjAs3S5o,447
6
+ bob_dev/helpers/__init__.py,sha256=4txLltQKoBei__spwrfAwRBVgN2rZWnN_LfImCPe4dg,50
7
+ bob_dev/helpers/config_helper.py,sha256=fr7d1-M6AwzxyEgyuyZEzgte2fEwW_gxEiuhN8yFbfI,3988
8
+ bob_dev/helpers/jira_helper.py,sha256=97M2MZ62NQSnSnOq_vgfHppIR2mOb0bjkzTO23_cuDU,2208
9
+ bob_dev/helpers/llm_helper.py,sha256=-zPPhkrOgkMBSPqi5YHfY1whafbwBJ-4HPIPN7QpNec,6495
10
+ bob_dev/helpers/project_helper.py,sha256=MRG6BL9DqrrDoEIPdAsNolD8rq1cUxiWDKxQn5MlIlw,3083
11
+ bob_dev/helpers/terminal.py,sha256=dWwhSinvctk3J3Il6l3Ky-KN5tfAf8ptSP5b-UG_SJk,4340
12
+ bob_dev-0.1.0.dist-info/licenses/LICENSE,sha256=mGS4YZDhM8Mb5BWvEXa_1u9ctDyhaAeFvy182Rkkv8U,744
13
+ bob_dev-0.1.0.dist-info/METADATA,sha256=wMokinb5mmv3gzTXinkHpvQlBx_1kGEjlqvpzWmVPJM,2975
14
+ bob_dev-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
15
+ bob_dev-0.1.0.dist-info/entry_points.txt,sha256=3XHdjzhLETqYvM5xmiuIvxxB0i9d3RkjlJ4uLeNrzKA,45
16
+ bob_dev-0.1.0.dist-info/top_level.txt,sha256=nbfFtA2C9OUmLV6EbTHCoOOWIDhNnnd4tcmzL01-j4w,17
17
+ bob_dev-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
+ bob-dev = bob_dev.cli:main
@@ -0,0 +1,17 @@
1
+ PRIVATE LICENSE
2
+
3
+ Copyright (c) 2024. All rights reserved.
4
+
5
+ This project and all its contents, including but not limited to source code, documentation,
6
+ and other materials, are proprietary and confidential. Unauthorized copying, distribution,
7
+ modification, or use of this material is strictly prohibited without express written permission
8
+ from the copyright holder.
9
+
10
+ RESTRICTIONS:
11
+ - This software is provided for authorized use only
12
+ - Copying, modifying, or distributing the software is prohibited
13
+ - Reverse engineering or decompiling is strictly forbidden
14
+ - Commercial use is not permitted without explicit authorization
15
+ - No warranties or guarantees are provided
16
+
17
+ For permission or licensing inquiries, please contact the copyright holder.
@@ -0,0 +1,2 @@
1
+ __init__
2
+ bob_dev