github-mcp-agent 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.
- github_mcp_agent/__init__.py +0 -0
- github_mcp_agent/agent.py +103 -0
- github_mcp_agent/cli.py +184 -0
- github_mcp_agent/providers/__init__.py +32 -0
- github_mcp_agent/providers/anthropic.py +28 -0
- github_mcp_agent/providers/bedrock.py +112 -0
- github_mcp_agent/providers/copilot.py +32 -0
- github_mcp_agent/providers/gemini.py +28 -0
- github_mcp_agent/providers/ollama.py +81 -0
- github_mcp_agent/providers/openai.py +28 -0
- github_mcp_agent/setup_wizard.py +134 -0
- github_mcp_agent/system_prompt.txt +57 -0
- github_mcp_agent/tools.py +214 -0
- github_mcp_agent-0.1.0.dist-info/METADATA +14 -0
- github_mcp_agent-0.1.0.dist-info/RECORD +17 -0
- github_mcp_agent-0.1.0.dist-info/WHEEL +4 -0
- github_mcp_agent-0.1.0.dist-info/entry_points.txt +2 -0
|
File without changes
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from contextlib import contextmanager
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from dotenv import load_dotenv
|
|
6
|
+
from strands import Agent
|
|
7
|
+
from strands.tools.mcp.mcp_client import MCPClient
|
|
8
|
+
from mcp import StdioServerParameters
|
|
9
|
+
from mcp.client.stdio import stdio_client
|
|
10
|
+
|
|
11
|
+
from github_mcp_agent.tools import detect_current_repo, local_tools
|
|
12
|
+
from github_mcp_agent import providers
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
_config_dir = Path.home() / ".config" / "github-mcp-agent"
|
|
16
|
+
load_dotenv(_config_dir / ".env")
|
|
17
|
+
load_dotenv()
|
|
18
|
+
|
|
19
|
+
MODEL_ID = os.getenv("MODEL_ID", "us.anthropic.claude-haiku-4-5-20251001-v1:0")
|
|
20
|
+
PROVIDER = os.getenv("PROVIDER", "bedrock")
|
|
21
|
+
VERBOSE = os.getenv("VERBOSE", "").lower() in ("1", "true", "yes")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _load_system_prompt() -> tuple[str, str | None]:
|
|
25
|
+
custom = _config_dir / "system_prompt.txt"
|
|
26
|
+
if custom.exists():
|
|
27
|
+
prompt = custom.read_text()
|
|
28
|
+
else:
|
|
29
|
+
from importlib.resources import files
|
|
30
|
+
prompt = files("github_mcp_agent").joinpath("system_prompt.txt").read_text()
|
|
31
|
+
|
|
32
|
+
repo = detect_current_repo()
|
|
33
|
+
if repo != "No GitHub remote detected":
|
|
34
|
+
prompt += f"\n\nThe user is currently working in the GitHub repository: {repo}. Default to this repository for all actions unless the user explicitly specifies another."
|
|
35
|
+
return prompt, repo
|
|
36
|
+
return prompt, None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _make_verbose_callback(provider: str):
|
|
40
|
+
import json as _json
|
|
41
|
+
is_ollama = provider == "ollama"
|
|
42
|
+
|
|
43
|
+
def callback(**kwargs):
|
|
44
|
+
if "current_tool_use" in kwargs:
|
|
45
|
+
tool = kwargs["current_tool_use"]
|
|
46
|
+
if tool.get("name"):
|
|
47
|
+
print(f"\n \033[2;36m> {tool['name']}\033[0m", flush=True)
|
|
48
|
+
if "data" in kwargs:
|
|
49
|
+
text = kwargs["data"]
|
|
50
|
+
if is_ollama:
|
|
51
|
+
try:
|
|
52
|
+
parsed = _json.loads(text)
|
|
53
|
+
if isinstance(parsed, dict) and "text" in parsed:
|
|
54
|
+
text = parsed["text"]
|
|
55
|
+
except Exception:
|
|
56
|
+
pass
|
|
57
|
+
print(text, end="", flush=True)
|
|
58
|
+
|
|
59
|
+
return callback
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@contextmanager
|
|
63
|
+
def create_agent(provider=None, model_id=None, verbose=False):
|
|
64
|
+
_provider = provider or PROVIDER
|
|
65
|
+
_model_id = model_id or MODEL_ID
|
|
66
|
+
|
|
67
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
68
|
+
if not token:
|
|
69
|
+
raise RuntimeError(
|
|
70
|
+
"GITHUB_TOKEN is not set. Run 'github-agent setup' to configure."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
mcp_client = MCPClient(
|
|
74
|
+
lambda: stdio_client(
|
|
75
|
+
StdioServerParameters(
|
|
76
|
+
command="docker",
|
|
77
|
+
args=[
|
|
78
|
+
"run", "-i", "--rm",
|
|
79
|
+
"-e", "GITHUB_PERSONAL_ACCESS_TOKEN",
|
|
80
|
+
"ghcr.io/github/github-mcp-server",
|
|
81
|
+
"stdio",
|
|
82
|
+
"--toolsets", "all",
|
|
83
|
+
"--log-file", "/dev/null",
|
|
84
|
+
],
|
|
85
|
+
env={"GITHUB_PERSONAL_ACCESS_TOKEN": token},
|
|
86
|
+
)
|
|
87
|
+
)
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
model = providers.build_model(_provider, _model_id)
|
|
91
|
+
system_prompt, current_repo = _load_system_prompt()
|
|
92
|
+
|
|
93
|
+
with mcp_client:
|
|
94
|
+
mcp_tools = mcp_client.list_tools_sync()
|
|
95
|
+
agent_kwargs = dict(model=model, tools=mcp_tools + local_tools, system_prompt=system_prompt)
|
|
96
|
+
if verbose or VERBOSE:
|
|
97
|
+
agent_kwargs["callback_handler"] = _make_verbose_callback(_provider)
|
|
98
|
+
else:
|
|
99
|
+
cb = providers.make_callback(_provider)
|
|
100
|
+
if cb:
|
|
101
|
+
agent_kwargs["callback_handler"] = cb
|
|
102
|
+
agent = Agent(**agent_kwargs)
|
|
103
|
+
yield agent, current_repo, len(mcp_tools) + len(local_tools)
|
github_mcp_agent/cli.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import readline
|
|
3
|
+
import select
|
|
4
|
+
import subprocess
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
import questionary
|
|
10
|
+
from rich.console import Console
|
|
11
|
+
from rich.rule import Rule
|
|
12
|
+
from rich.text import Text
|
|
13
|
+
|
|
14
|
+
console = Console()
|
|
15
|
+
|
|
16
|
+
CONFIG_DIR = Path.home() / ".config" / "github-mcp-agent"
|
|
17
|
+
|
|
18
|
+
_PROVIDER_CHOICES = [
|
|
19
|
+
"AWS Bedrock", "Anthropic API", "OpenAI", "Google Gemini", "GitHub Copilot", "Local (Ollama)"
|
|
20
|
+
]
|
|
21
|
+
_PROVIDER_KEY = {
|
|
22
|
+
"AWS Bedrock": "bedrock",
|
|
23
|
+
"Anthropic API": "anthropic",
|
|
24
|
+
"OpenAI": "openai",
|
|
25
|
+
"Google Gemini": "gemini",
|
|
26
|
+
"GitHub Copilot": "copilot",
|
|
27
|
+
"Local (Ollama)": "ollama",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _pick_model_for_provider(provider: str, _ask) -> tuple[str, str]:
|
|
32
|
+
import github_mcp_agent.providers as pkg
|
|
33
|
+
mod = getattr(pkg, provider)
|
|
34
|
+
console.print(f" [dim]Provider: {provider} (to switch, run: github-agent provider)[/dim]")
|
|
35
|
+
if provider == "ollama":
|
|
36
|
+
model_id = mod.pick_model(_ask)
|
|
37
|
+
else:
|
|
38
|
+
model_choices = [f"{name} ({desc})" for _, name, desc in mod.MODELS]
|
|
39
|
+
model_display = _ask(questionary.select, "Model:", choices=model_choices)
|
|
40
|
+
model_id = mod.MODELS[model_choices.index(model_display)][0]
|
|
41
|
+
return provider, model_id
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _run_agent(verbose: bool = False):
|
|
45
|
+
from github_mcp_agent.agent import MODEL_ID, PROVIDER, create_agent
|
|
46
|
+
try:
|
|
47
|
+
with create_agent(verbose=verbose) as (agent, current_repo, total_tools):
|
|
48
|
+
console.print()
|
|
49
|
+
console.print(Rule("[bold green]GitHub MCP Agent[/bold green]"))
|
|
50
|
+
repo_label = f"[bold]{current_repo}[/bold]" if current_repo else "[dim]none detected[/dim]"
|
|
51
|
+
console.print(f" [dim]Loaded [bold]{total_tools}[/bold] tools | Repo: {repo_label} | Provider: [bold]{PROVIDER}[/bold] | Model: [bold]{MODEL_ID}[/bold] | Type 'exit' to quit[/dim]")
|
|
52
|
+
console.print(Rule())
|
|
53
|
+
console.print()
|
|
54
|
+
|
|
55
|
+
while True:
|
|
56
|
+
try:
|
|
57
|
+
user_input = input("\033[1;36m You > \033[0m")
|
|
58
|
+
while select.select([sys.stdin], [], [], 0.05)[0]:
|
|
59
|
+
user_input += "\n" + sys.stdin.readline().rstrip("\n")
|
|
60
|
+
except (KeyboardInterrupt, EOFError):
|
|
61
|
+
break
|
|
62
|
+
|
|
63
|
+
if not user_input.strip():
|
|
64
|
+
continue
|
|
65
|
+
if user_input.strip().lower() in ["exit", "quit"]:
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
console.print()
|
|
69
|
+
console.print(Text(" Agent", style="bold magenta"))
|
|
70
|
+
console.print()
|
|
71
|
+
|
|
72
|
+
try:
|
|
73
|
+
agent(user_input)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
console.print(f"[bold red]Error:[/bold red] {e}")
|
|
76
|
+
|
|
77
|
+
console.print()
|
|
78
|
+
|
|
79
|
+
console.print()
|
|
80
|
+
console.print(Rule("[dim]Session ended[/dim]"))
|
|
81
|
+
console.print()
|
|
82
|
+
|
|
83
|
+
except RuntimeError as e:
|
|
84
|
+
console.print(f"\n[bold red]{e}[/bold red]\n")
|
|
85
|
+
sys.exit(1)
|
|
86
|
+
except Exception as e:
|
|
87
|
+
console.print(f"\n[bold red]Failed to start:[/bold red] {e}\n")
|
|
88
|
+
sys.exit(1)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _open_config():
|
|
92
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
93
|
+
config_file = CONFIG_DIR / ".env"
|
|
94
|
+
if not config_file.exists():
|
|
95
|
+
config_file.write_text(
|
|
96
|
+
"GITHUB_TOKEN=\nAWS_PROFILE=default\nAWS_REGION=us-east-1\nMODEL_ID=us.anthropic.claude-haiku-4-5-20251001-v1:0\n"
|
|
97
|
+
)
|
|
98
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
99
|
+
subprocess.run([editor, str(config_file)])
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _open_prompt():
|
|
103
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
104
|
+
prompt_file = CONFIG_DIR / "system_prompt.txt"
|
|
105
|
+
if not prompt_file.exists():
|
|
106
|
+
from importlib.resources import files
|
|
107
|
+
default = files("github_mcp_agent").joinpath("system_prompt.txt").read_text()
|
|
108
|
+
prompt_file.write_text(default)
|
|
109
|
+
console.print(f" [dim]Created {prompt_file} with default prompt - edit to customize.[/dim]\n")
|
|
110
|
+
editor = os.environ.get("EDITOR", "nano")
|
|
111
|
+
subprocess.run([editor, str(prompt_file)])
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
@click.group(invoke_without_command=True, context_settings={"help_option_names": ["-h", "--help"]})
|
|
115
|
+
@click.option("--verbose", "-v", is_flag=True, default=False, help="Print tool calls as they happen")
|
|
116
|
+
@click.pass_context
|
|
117
|
+
def cli(ctx, verbose):
|
|
118
|
+
"""GitHub MCP Agent - talk to your repos in plain English."""
|
|
119
|
+
if ctx.invoked_subcommand is None:
|
|
120
|
+
_run_agent(verbose=verbose)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
@cli.command()
|
|
124
|
+
def setup():
|
|
125
|
+
"""Run the interactive setup wizard."""
|
|
126
|
+
from github_mcp_agent.setup_wizard import run
|
|
127
|
+
run()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@cli.command(name="provider")
|
|
131
|
+
def switch_provider():
|
|
132
|
+
"""Switch AI provider - pick credentials + model and save to config."""
|
|
133
|
+
from github_mcp_agent.setup_wizard import _ask, _write_config
|
|
134
|
+
from github_mcp_agent import providers as _providers
|
|
135
|
+
provider_display = _ask(questionary.select, "Provider:", choices=_PROVIDER_CHOICES)
|
|
136
|
+
provider_key = _PROVIDER_KEY[provider_display]
|
|
137
|
+
provider_values = _providers.setup(provider_key, _ask)
|
|
138
|
+
_write_config({"PROVIDER": provider_key, **provider_values})
|
|
139
|
+
console.print(" [green]Saved.[/green]")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@cli.command(name="model")
|
|
143
|
+
def switch_model():
|
|
144
|
+
"""Pick a model for the current provider and save to config."""
|
|
145
|
+
from github_mcp_agent.agent import PROVIDER
|
|
146
|
+
from github_mcp_agent.setup_wizard import _ask, _write_config
|
|
147
|
+
_, effective_model = _pick_model_for_provider(PROVIDER, _ask)
|
|
148
|
+
_write_config({"MODEL_ID": effective_model})
|
|
149
|
+
console.print(f" [green]Saved:[/green] model={effective_model}")
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@cli.command()
|
|
153
|
+
def token():
|
|
154
|
+
"""Update your GitHub Personal Access Token."""
|
|
155
|
+
from github_mcp_agent.setup_wizard import _ask, _validate_github_token, _write_config
|
|
156
|
+
new_token = _ask(questionary.password, "GitHub Personal Access Token:")
|
|
157
|
+
console.print(" Validating...", end=" ")
|
|
158
|
+
if _validate_github_token(new_token):
|
|
159
|
+
console.print("[green]valid[/green]")
|
|
160
|
+
_write_config({"GITHUB_TOKEN": new_token})
|
|
161
|
+
console.print(" [green]Saved.[/green]")
|
|
162
|
+
else:
|
|
163
|
+
console.print("[red]invalid - check the token and scopes[/red]")
|
|
164
|
+
sys.exit(1)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
@cli.command()
|
|
168
|
+
def config():
|
|
169
|
+
"""Open the config file in $EDITOR."""
|
|
170
|
+
_open_config()
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
@cli.command()
|
|
174
|
+
def prompt():
|
|
175
|
+
"""Open the system prompt file in $EDITOR."""
|
|
176
|
+
_open_prompt()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main():
|
|
180
|
+
cli()
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
if __name__ == "__main__":
|
|
184
|
+
main()
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
from . import anthropic, bedrock, copilot, gemini, ollama, openai
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
_REGISTRY = {
|
|
5
|
+
"bedrock": bedrock,
|
|
6
|
+
"anthropic": anthropic,
|
|
7
|
+
"openai": openai,
|
|
8
|
+
"gemini": gemini,
|
|
9
|
+
"copilot": copilot,
|
|
10
|
+
"ollama": ollama,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_model(provider: str, model_id: str):
|
|
15
|
+
mod = _REGISTRY.get(provider)
|
|
16
|
+
if mod is None:
|
|
17
|
+
raise RuntimeError(f"Unknown provider: {provider}")
|
|
18
|
+
return mod.build_model(model_id)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def make_callback(provider: str):
|
|
22
|
+
if provider == "ollama":
|
|
23
|
+
return ollama.make_callback()
|
|
24
|
+
return None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def setup(provider: str, _ask) -> dict:
|
|
28
|
+
mod = _REGISTRY.get(provider)
|
|
29
|
+
if mod is None:
|
|
30
|
+
raise RuntimeError(f"Unknown provider: {provider}")
|
|
31
|
+
return mod.setup(_ask)
|
|
32
|
+
pypi-AgEIcHlwaS5vcmcCJGU0ZDI1YWQ5LTEyOGMtNDUyZi1iNmZkLTEzOWU0MmRkNDIzOAACKlszLCJjZTAzNzUyYy00ZjNhLTQ3MTYtYjM2ZS02YmY0ZGNhZjkyMDciXQAABiD04xa6bIcHRFlnmMHzKPunpg81Z6OhNO_P102kWxtY9g
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import questionary
|
|
4
|
+
|
|
5
|
+
MODELS = [
|
|
6
|
+
("claude-haiku-4-5-20251001", "claude-haiku-4-5", "fastest"),
|
|
7
|
+
("claude-sonnet-4-6", "claude-sonnet-4-6", "balanced"),
|
|
8
|
+
("claude-opus-4-7", "claude-opus-4-7", "most capable"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_model(model_id: str):
|
|
13
|
+
from strands.models.anthropic import AnthropicModel
|
|
14
|
+
if not os.environ.get("ANTHROPIC_API_KEY"):
|
|
15
|
+
raise RuntimeError("ANTHROPIC_API_KEY is not set. Run 'github-agent setup' to configure.")
|
|
16
|
+
return AnthropicModel(model_id=model_id, max_tokens=8096)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup(_ask) -> dict:
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
Console().print(" [dim]Get your key at: console.anthropic.com/settings/keys[/dim]")
|
|
22
|
+
key = _ask(questionary.password, "Anthropic API key (sk-ant-...):")
|
|
23
|
+
model_choices = [f"{name} ({desc})" for _, name, desc in MODELS]
|
|
24
|
+
model_display = _ask(questionary.select, "Model:", choices=model_choices)
|
|
25
|
+
return {
|
|
26
|
+
"ANTHROPIC_API_KEY": key,
|
|
27
|
+
"MODEL_ID": MODELS[model_choices.index(model_display)][0],
|
|
28
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import questionary
|
|
6
|
+
|
|
7
|
+
REGIONS = [
|
|
8
|
+
("us-east-1", "N. Virginia - recommended"),
|
|
9
|
+
("us-west-2", "Oregon"),
|
|
10
|
+
("eu-west-1", "Ireland"),
|
|
11
|
+
("eu-central-1", "Frankfurt"),
|
|
12
|
+
("eu-west-3", "Paris"),
|
|
13
|
+
("ap-northeast-1", "Tokyo"),
|
|
14
|
+
("ap-southeast-1", "Singapore"),
|
|
15
|
+
("ap-southeast-2", "Sydney"),
|
|
16
|
+
("ca-central-1", "Canada"),
|
|
17
|
+
("sa-east-1", "Sao Paulo"),
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
MODELS = [
|
|
21
|
+
("us.anthropic.claude-haiku-4-5-20251001-v1:0", "claude-haiku-4-5", "fastest, cheapest"),
|
|
22
|
+
("us.anthropic.claude-sonnet-4-6", "claude-sonnet-4-6", "balanced"),
|
|
23
|
+
("us.anthropic.claude-opus-4-7", "claude-opus-4-7", "most capable"),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def build_model(model_id: str):
|
|
28
|
+
from strands.models import BedrockModel
|
|
29
|
+
return BedrockModel(model_id=model_id, region_name=os.getenv("AWS_REGION", "us-east-1"))
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _list_aws_profiles() -> list[str]:
|
|
33
|
+
profiles = []
|
|
34
|
+
for f in [Path.home() / ".aws" / "credentials", Path.home() / ".aws" / "config"]:
|
|
35
|
+
if f.exists():
|
|
36
|
+
for line in f.read_text().splitlines():
|
|
37
|
+
if line.startswith("[") and line.endswith("]"):
|
|
38
|
+
name = line[1:-1].replace("profile ", "")
|
|
39
|
+
if name not in profiles:
|
|
40
|
+
profiles.append(name)
|
|
41
|
+
return profiles
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _validate_aws_profile(profile: str) -> bool:
|
|
45
|
+
env = os.environ.copy()
|
|
46
|
+
env["AWS_PROFILE"] = profile
|
|
47
|
+
return subprocess.run(["aws", "sts", "get-caller-identity"], capture_output=True, env=env).returncode == 0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def setup(_ask) -> dict:
|
|
51
|
+
values = {}
|
|
52
|
+
|
|
53
|
+
while True:
|
|
54
|
+
cred_method = _ask(
|
|
55
|
+
questionary.select,
|
|
56
|
+
"AWS credentials:",
|
|
57
|
+
choices=["Use existing profile", "Enter access keys directly", "AWS SSO / browser login"],
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
if cred_method == "Use existing profile":
|
|
61
|
+
profiles = _list_aws_profiles()
|
|
62
|
+
choices = profiles + ["other (type manually)"] if profiles else ["other (type manually)"]
|
|
63
|
+
choice = _ask(questionary.select, "AWS profile:", choices=choices)
|
|
64
|
+
if choice == "other (type manually)":
|
|
65
|
+
profile = _ask(questionary.text, "Profile name:", default="default")
|
|
66
|
+
else:
|
|
67
|
+
profile = choice
|
|
68
|
+
from rich.console import Console
|
|
69
|
+
Console().print(" Validating...", end=" ")
|
|
70
|
+
if _validate_aws_profile(profile):
|
|
71
|
+
Console().print("[green]valid[/green]")
|
|
72
|
+
values["AWS_PROFILE"] = profile
|
|
73
|
+
break
|
|
74
|
+
else:
|
|
75
|
+
Console().print("[red]invalid - check your AWS credentials[/red]")
|
|
76
|
+
|
|
77
|
+
elif cred_method == "Enter access keys directly":
|
|
78
|
+
values["AWS_ACCESS_KEY_ID"] = _ask(questionary.text, "AWS Access Key ID:")
|
|
79
|
+
values["AWS_SECRET_ACCESS_KEY"] = _ask(questionary.password, "AWS Secret Access Key:")
|
|
80
|
+
session_token = _ask(questionary.text, "AWS Session Token (leave blank if none):")
|
|
81
|
+
if session_token:
|
|
82
|
+
values["AWS_SESSION_TOKEN"] = session_token
|
|
83
|
+
break
|
|
84
|
+
|
|
85
|
+
elif cred_method == "AWS SSO / browser login":
|
|
86
|
+
profiles = _list_aws_profiles()
|
|
87
|
+
choices = profiles + ["other (type manually)"] if profiles else ["other (type manually)"]
|
|
88
|
+
choice = _ask(questionary.select, "SSO profile to use:", choices=choices)
|
|
89
|
+
if choice == "other (type manually)":
|
|
90
|
+
profile = _ask(questionary.text, "Profile name:")
|
|
91
|
+
else:
|
|
92
|
+
profile = choice
|
|
93
|
+
from rich.console import Console
|
|
94
|
+
console = Console()
|
|
95
|
+
console.print(f"\n Running [bold]aws sso login --profile {profile}[/bold]")
|
|
96
|
+
result = subprocess.run(["aws", "sso", "login", "--profile", profile])
|
|
97
|
+
if result.returncode == 0:
|
|
98
|
+
values["AWS_PROFILE"] = profile
|
|
99
|
+
break
|
|
100
|
+
console.print("[red]SSO login failed - profile may not be configured for SSO.[/red]")
|
|
101
|
+
console.print("[dim] Run: aws configure sso --profile <name>[/dim]")
|
|
102
|
+
console.print("[dim] Or pick a different credential method below.[/dim]\n")
|
|
103
|
+
|
|
104
|
+
region_choices = [f"{r} ({label})" for r, label in REGIONS]
|
|
105
|
+
region_display = _ask(questionary.select, "AWS Region:", choices=region_choices)
|
|
106
|
+
values["AWS_REGION"] = region_display.split()[0]
|
|
107
|
+
|
|
108
|
+
model_choices = [f"{name} ({desc}) -> {mid}" for mid, name, desc in MODELS]
|
|
109
|
+
model_display = _ask(questionary.select, "Model:", choices=model_choices)
|
|
110
|
+
values["MODEL_ID"] = MODELS[model_choices.index(model_display)][0]
|
|
111
|
+
|
|
112
|
+
return values
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import questionary
|
|
4
|
+
|
|
5
|
+
MODELS = [
|
|
6
|
+
("gpt-4o", "gpt-4o", "flagship"),
|
|
7
|
+
("gpt-4o-mini", "gpt-4o-mini", "fast, cheap"),
|
|
8
|
+
("claude-sonnet-4-5", "claude-sonnet-4-5", "Anthropic via Copilot"),
|
|
9
|
+
("o3-mini", "o3-mini", "reasoning"),
|
|
10
|
+
("gemini-1.5-pro", "gemini-1.5-pro", "Google via Copilot"),
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_model(model_id: str):
|
|
15
|
+
from strands.models.litellm import LiteLLMModel
|
|
16
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
17
|
+
if not token:
|
|
18
|
+
raise RuntimeError("GITHUB_TOKEN is not set. Run 'github-agent setup' to configure.")
|
|
19
|
+
return LiteLLMModel(
|
|
20
|
+
model_id=f"openai/{model_id}",
|
|
21
|
+
params={"api_base": "https://api.githubcopilot.com", "api_key": token},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def setup(_ask) -> dict:
|
|
26
|
+
from rich.console import Console
|
|
27
|
+
console = Console()
|
|
28
|
+
console.print(" [dim]Uses your GitHub token - no extra API key needed.[/dim]")
|
|
29
|
+
console.print(" [dim]Requires an active Copilot subscription (student pack, individual, or business).[/dim]")
|
|
30
|
+
model_choices = [f"{name} ({desc})" for _, name, desc in MODELS]
|
|
31
|
+
model_display = _ask(questionary.select, "Model:", choices=model_choices)
|
|
32
|
+
return {"MODEL_ID": MODELS[model_choices.index(model_display)][0]}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import questionary
|
|
4
|
+
|
|
5
|
+
MODELS = [
|
|
6
|
+
("gemini-2.0-flash", "gemini-2.0-flash", "fast, recommended"),
|
|
7
|
+
("gemini-1.5-pro", "gemini-1.5-pro", "most capable"),
|
|
8
|
+
("gemini-1.5-flash", "gemini-1.5-flash", "fast"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_model(model_id: str):
|
|
13
|
+
from strands.models.litellm import LiteLLMModel
|
|
14
|
+
if not os.environ.get("GEMINI_API_KEY"):
|
|
15
|
+
raise RuntimeError("GEMINI_API_KEY is not set. Run 'github-agent setup' to configure.")
|
|
16
|
+
return LiteLLMModel(model_id=f"gemini/{model_id}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup(_ask) -> dict:
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
Console().print(" [dim]Get your key at: aistudio.google.com/apikey[/dim]")
|
|
22
|
+
key = _ask(questionary.password, "Google AI Studio API key:")
|
|
23
|
+
model_choices = [f"{name} ({desc})" for _, name, desc in MODELS]
|
|
24
|
+
model_display = _ask(questionary.select, "Model:", choices=model_choices)
|
|
25
|
+
return {
|
|
26
|
+
"GEMINI_API_KEY": key,
|
|
27
|
+
"MODEL_ID": MODELS[model_choices.index(model_display)][0],
|
|
28
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import subprocess
|
|
3
|
+
|
|
4
|
+
import questionary
|
|
5
|
+
|
|
6
|
+
POPULAR_MODELS = [
|
|
7
|
+
"llama3.2",
|
|
8
|
+
"llama3.1",
|
|
9
|
+
"mistral",
|
|
10
|
+
"gemma2",
|
|
11
|
+
"phi3",
|
|
12
|
+
"codellama",
|
|
13
|
+
"deepseek-r1",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def build_model(model_id: str):
|
|
18
|
+
from strands.models.litellm import LiteLLMModel
|
|
19
|
+
base_url = os.environ.get("OLLAMA_BASE_URL", "http://localhost:11434")
|
|
20
|
+
return LiteLLMModel(model_id=f"ollama/{model_id}", params={"api_base": base_url})
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def make_callback():
|
|
24
|
+
import json as _json
|
|
25
|
+
|
|
26
|
+
def callback(**kwargs):
|
|
27
|
+
if "data" in kwargs:
|
|
28
|
+
text = kwargs["data"]
|
|
29
|
+
try:
|
|
30
|
+
parsed = _json.loads(text)
|
|
31
|
+
if isinstance(parsed, dict) and "text" in parsed:
|
|
32
|
+
text = parsed["text"]
|
|
33
|
+
except Exception:
|
|
34
|
+
pass
|
|
35
|
+
print(text, end="", flush=True)
|
|
36
|
+
|
|
37
|
+
return callback
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def pick_model(_ask) -> str:
|
|
41
|
+
result = subprocess.run(["ollama", "list"], capture_output=True, text=True)
|
|
42
|
+
installed = [line.split()[0] for line in result.stdout.splitlines()[1:] if line.split()]
|
|
43
|
+
choices = (installed if installed else POPULAR_MODELS) + ["other (type manually)"]
|
|
44
|
+
choice = _ask(questionary.select, "Model:", choices=choices)
|
|
45
|
+
if choice == "other (type manually)":
|
|
46
|
+
return _ask(questionary.text, "Model name:")
|
|
47
|
+
return choice
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def setup(_ask) -> dict:
|
|
51
|
+
from rich.console import Console
|
|
52
|
+
console = Console()
|
|
53
|
+
|
|
54
|
+
result = subprocess.run(["ollama", "list"], capture_output=True, text=True)
|
|
55
|
+
installed = []
|
|
56
|
+
if result.returncode == 0:
|
|
57
|
+
for line in result.stdout.splitlines()[1:]:
|
|
58
|
+
parts = line.split()
|
|
59
|
+
if parts:
|
|
60
|
+
installed.append(parts[0])
|
|
61
|
+
|
|
62
|
+
if installed:
|
|
63
|
+
choices = installed + ["other (type manually)"]
|
|
64
|
+
choice = _ask(questionary.select, "Model:", choices=choices)
|
|
65
|
+
if choice == "other (type manually)":
|
|
66
|
+
model = _ask(questionary.text, "Model name (e.g. llama3.2):")
|
|
67
|
+
else:
|
|
68
|
+
model = choice
|
|
69
|
+
else:
|
|
70
|
+
console.print(" [dim]No models installed yet. Pick one to pull:[/dim]")
|
|
71
|
+
choices = POPULAR_MODELS + ["other (type manually)"]
|
|
72
|
+
choice = _ask(questionary.select, "Model:", choices=choices)
|
|
73
|
+
if choice == "other (type manually)":
|
|
74
|
+
model = _ask(questionary.text, "Model name:")
|
|
75
|
+
else:
|
|
76
|
+
model = choice
|
|
77
|
+
console.print(f"\n Running [bold]ollama pull {model}[/bold]")
|
|
78
|
+
subprocess.run(["ollama", "pull", model])
|
|
79
|
+
|
|
80
|
+
base_url = _ask(questionary.text, "Ollama base URL:", default="http://localhost:11434")
|
|
81
|
+
return {"MODEL_ID": model, "OLLAMA_BASE_URL": base_url}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import os
|
|
2
|
+
|
|
3
|
+
import questionary
|
|
4
|
+
|
|
5
|
+
MODELS = [
|
|
6
|
+
("gpt-4o-mini", "gpt-4o-mini", "fast, cheap"),
|
|
7
|
+
("gpt-4o", "gpt-4o", "flagship"),
|
|
8
|
+
("gpt-4-turbo", "gpt-4-turbo", "legacy"),
|
|
9
|
+
]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def build_model(model_id: str):
|
|
13
|
+
from strands.models.litellm import LiteLLMModel
|
|
14
|
+
if not os.environ.get("OPENAI_API_KEY"):
|
|
15
|
+
raise RuntimeError("OPENAI_API_KEY is not set. Run 'github-agent setup' to configure.")
|
|
16
|
+
return LiteLLMModel(model_id=f"openai/{model_id}")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def setup(_ask) -> dict:
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
Console().print(" [dim]Get your key at: platform.openai.com/api-keys[/dim]")
|
|
22
|
+
key = _ask(questionary.password, "OpenAI API key (sk-...):")
|
|
23
|
+
model_choices = [f"{name} ({desc})" for _, name, desc in MODELS]
|
|
24
|
+
model_display = _ask(questionary.select, "Model:", choices=model_choices)
|
|
25
|
+
return {
|
|
26
|
+
"OPENAI_API_KEY": key,
|
|
27
|
+
"MODEL_ID": MODELS[model_choices.index(model_display)][0],
|
|
28
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import subprocess
|
|
2
|
+
import sys
|
|
3
|
+
import urllib.request
|
|
4
|
+
import urllib.error
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import questionary
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
from rich.rule import Rule
|
|
10
|
+
|
|
11
|
+
from github_mcp_agent import providers
|
|
12
|
+
|
|
13
|
+
console = Console()
|
|
14
|
+
CONFIG_DIR = Path.home() / ".config" / "github-mcp-agent"
|
|
15
|
+
CONFIG_FILE = CONFIG_DIR / ".env"
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _check(label: str, ok: bool, hint: str = ""):
|
|
19
|
+
if ok:
|
|
20
|
+
console.print(f" [green]OK[/green] {label}")
|
|
21
|
+
else:
|
|
22
|
+
console.print(f" [red]FAIL[/red] {label}" + (f" - {hint}" if hint else ""))
|
|
23
|
+
return ok
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _check_prerequisites(provider: str = "bedrock") -> bool:
|
|
27
|
+
console.print("\n[bold]Checking prerequisites...[/bold]")
|
|
28
|
+
ok = True
|
|
29
|
+
py = sys.version_info >= (3, 10)
|
|
30
|
+
ok &= _check("Python >= 3.10", py, f"found {sys.version.split()[0]}")
|
|
31
|
+
docker = subprocess.run(["docker", "info"], capture_output=True).returncode == 0
|
|
32
|
+
ok &= _check("Docker installed and running", docker, "install Docker from docker.com")
|
|
33
|
+
if provider == "bedrock":
|
|
34
|
+
aws = subprocess.run(["aws", "--version"], capture_output=True).returncode == 0
|
|
35
|
+
ok &= _check("AWS CLI installed", aws, "install from aws.amazon.com/cli")
|
|
36
|
+
if provider == "ollama":
|
|
37
|
+
ollama_ok = subprocess.run(["ollama", "list"], capture_output=True).returncode == 0
|
|
38
|
+
ok &= _check("Ollama installed and running", ollama_ok, "install from ollama.com then run: ollama serve")
|
|
39
|
+
return ok
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _ask(fn, *args, **kwargs):
|
|
43
|
+
try:
|
|
44
|
+
val = fn(*args, **kwargs).ask()
|
|
45
|
+
except (KeyboardInterrupt, EOFError):
|
|
46
|
+
console.print("\n[yellow]Setup cancelled.[/yellow]")
|
|
47
|
+
sys.exit(0)
|
|
48
|
+
if val is None:
|
|
49
|
+
console.print("\n[yellow]Setup cancelled.[/yellow]")
|
|
50
|
+
sys.exit(0)
|
|
51
|
+
return val
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _validate_github_token(token: str) -> bool:
|
|
55
|
+
req = urllib.request.Request("https://api.github.com/user")
|
|
56
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
57
|
+
req.add_header("User-Agent", "github-mcp-agent")
|
|
58
|
+
try:
|
|
59
|
+
with urllib.request.urlopen(req) as r:
|
|
60
|
+
return r.status == 200
|
|
61
|
+
except urllib.error.HTTPError:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _pull_docker_image():
|
|
66
|
+
console.print("\n[bold]Pulling GitHub MCP Docker image...[/bold]")
|
|
67
|
+
subprocess.run(["docker", "pull", "ghcr.io/github/github-mcp-server"], check=False)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _write_config(values: dict):
|
|
71
|
+
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
72
|
+
existing = {}
|
|
73
|
+
if CONFIG_FILE.exists():
|
|
74
|
+
for line in CONFIG_FILE.read_text().splitlines():
|
|
75
|
+
if "=" in line and not line.startswith("#"):
|
|
76
|
+
k, _, v = line.partition("=")
|
|
77
|
+
existing[k.strip()] = v.strip()
|
|
78
|
+
existing.update({k: v for k, v in values.items() if v})
|
|
79
|
+
lines = [f"{k}={v}" for k, v in existing.items()]
|
|
80
|
+
CONFIG_FILE.write_text("\n".join(lines) + "\n")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def run():
|
|
84
|
+
console.print()
|
|
85
|
+
console.print(Rule("[bold green]GitHub MCP Agent - Setup[/bold green]"))
|
|
86
|
+
|
|
87
|
+
console.print()
|
|
88
|
+
console.print(" [dim]Create a token at: github.com/settings/tokens[/dim]")
|
|
89
|
+
console.print(" [dim]Required scopes: repo, read:org, project[/dim]")
|
|
90
|
+
token = _ask(questionary.password, "GitHub Personal Access Token:")
|
|
91
|
+
if token:
|
|
92
|
+
console.print(" Validating...", end=" ")
|
|
93
|
+
if _validate_github_token(token):
|
|
94
|
+
console.print("[green]valid[/green]")
|
|
95
|
+
else:
|
|
96
|
+
console.print("[red]invalid - check the token and scopes[/red]")
|
|
97
|
+
sys.exit(1)
|
|
98
|
+
|
|
99
|
+
console.print()
|
|
100
|
+
|
|
101
|
+
provider_display = _ask(
|
|
102
|
+
questionary.select,
|
|
103
|
+
"AI provider:",
|
|
104
|
+
choices=["AWS Bedrock", "Anthropic API", "OpenAI", "Google Gemini", "GitHub Copilot", "Local (Ollama)"],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
provider_key = {
|
|
108
|
+
"AWS Bedrock": "bedrock",
|
|
109
|
+
"Anthropic API": "anthropic",
|
|
110
|
+
"OpenAI": "openai",
|
|
111
|
+
"Google Gemini": "gemini",
|
|
112
|
+
"GitHub Copilot": "copilot",
|
|
113
|
+
"Local (Ollama)": "ollama",
|
|
114
|
+
}[provider_display]
|
|
115
|
+
|
|
116
|
+
if not _check_prerequisites(provider=provider_key):
|
|
117
|
+
console.print("\n[red]Fix the issues above before continuing.[/red]")
|
|
118
|
+
sys.exit(1)
|
|
119
|
+
|
|
120
|
+
console.print()
|
|
121
|
+
|
|
122
|
+
provider_values = providers.setup(provider_key, _ask)
|
|
123
|
+
|
|
124
|
+
_write_config({"GITHUB_TOKEN": token, "PROVIDER": provider_key, **provider_values})
|
|
125
|
+
console.print(f"\n [dim]Config saved to {CONFIG_FILE}[/dim]")
|
|
126
|
+
|
|
127
|
+
_pull_docker_image()
|
|
128
|
+
|
|
129
|
+
console.print()
|
|
130
|
+
console.print(Rule("[bold green]Setup complete[/bold green]"))
|
|
131
|
+
console.print()
|
|
132
|
+
console.print(" Run [bold cyan]github-agent[/bold cyan] to start.")
|
|
133
|
+
console.print(" Run [bold cyan]github-agent prompt[/bold cyan] to customize the system prompt.")
|
|
134
|
+
console.print()
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
You are a GitHub project management assistant with full access to GitHub tools.
|
|
2
|
+
|
|
3
|
+
When a user asks about a repository, user, issue, PR, or anything GitHub-related, ALWAYS use your tools to look it up. Never say you don't have access or ask the user to provide information you can find yourself.
|
|
4
|
+
|
|
5
|
+
You also have two local file tools: read_local_file and list_local_files. Use these to read files directly from the user's local repo (e.g. TODO.md, README, config files) without going through the GitHub API.
|
|
6
|
+
|
|
7
|
+
Examples of proactive tool use:
|
|
8
|
+
- "what issues are open?" -> search for issues using the tools, do not ask which repo
|
|
9
|
+
- "summarize this repo" -> fetch the repo, read files, list issues and PRs
|
|
10
|
+
- "who contributed most?" -> look it up via the API
|
|
11
|
+
- If the user mentions a repo name or username, use it immediately without asking for clarification
|
|
12
|
+
- If a name search fails, try variations: different casing, hyphens vs underscores, with/without year suffixes, abbreviated names. Try at least 3-4 variations before giving up.
|
|
13
|
+
- If an org name is ambiguous, search for the org by keyword using search tools before asking the user.
|
|
14
|
+
|
|
15
|
+
Terminology - understand the difference:
|
|
16
|
+
- "GitHub Project" or "project board" = a kanban-style board for organizing issues and tasks (GitHub Projects v2). Use project-related tools to create and manage these.
|
|
17
|
+
- "repository" or "repo" = a codebase. Do NOT create a repository when the user asks for a "project board".
|
|
18
|
+
- When the user says "create a project", "create a github project", "project board", or "add issues to a project" they mean GitHub Projects, not a new repository.
|
|
19
|
+
|
|
20
|
+
GitHub Projects - field workflow:
|
|
21
|
+
- Fields like Status, Priority, Size, Estimate, and Iteration already exist on most projects. Never assume they don't exist.
|
|
22
|
+
- CRITICAL: Priority and Type are native GitHub ISSUE fields. Set them directly on the issue via issue_write. NEVER use projects_write for them. NEVER use labels for them.
|
|
23
|
+
|
|
24
|
+
Mandatory workflow when creating issues:
|
|
25
|
+
1. Create the issue with issue_write — set "type" here (e.g. type: "Feature", type: "Bug")
|
|
26
|
+
2. Set priority using the set_issue_priority tool — parameters: owner, repo, issue_number, priority ("urgent"/"high"/"medium"/"low")
|
|
27
|
+
3. Add the issue to the project with projects_write method "add_project_item"
|
|
28
|
+
That's it. No project field updates needed for Priority or Type.
|
|
29
|
+
|
|
30
|
+
To update priority on an existing issue: use set_issue_priority directly.
|
|
31
|
+
GitHub's REST API does NOT support updating single_select project fields via projects_write (returns 400 "unsupported"). Do not attempt it.
|
|
32
|
+
|
|
33
|
+
When setting Status on a project item:
|
|
34
|
+
- Use projects_write method "update_project_item" ONLY for Status (not Priority, not Type)
|
|
35
|
+
- Get the Status field ID and option IDs first via projects_list method "list_project_fields"
|
|
36
|
+
- Status option IDs look like hex strings: "f75ad846", "47fc9ee4" — NOT integers
|
|
37
|
+
- Set Status to "Todo" by default unless told otherwise
|
|
38
|
+
- Do not set Size, Estimate, or Iteration unless explicitly asked.
|
|
39
|
+
|
|
40
|
+
Reading issues:
|
|
41
|
+
- NEVER call list_issues. It is forbidden. Always use list_issues_with_priorities instead — it returns number, title, priority, description, and state in one call.
|
|
42
|
+
|
|
43
|
+
Reading project board status (Todo / In Progress / Done):
|
|
44
|
+
- GitHub issue states (OPEN/CLOSED) are NOT the same as project board status.
|
|
45
|
+
- When the user asks what is "in progress", "todo", or "done", ALWAYS use the list_board_status tool. Never use projects_list for this — it causes context overflow.
|
|
46
|
+
- list_board_status requires owner (org name) and project_number (integer). To find the project number, use projects_list method "list_projects" with the owner first.
|
|
47
|
+
- Never tell the user that "in progress" doesn't exist just because issues are OPEN — always check the project board with list_board_status first.
|
|
48
|
+
|
|
49
|
+
Rules:
|
|
50
|
+
- Never delete repositories.
|
|
51
|
+
- Never change repository settings.
|
|
52
|
+
- Never create more than 5 issues at once unless clearly asked.
|
|
53
|
+
- Before creating issues, summarize what you are about to create.
|
|
54
|
+
- You are running in a terminal. Use plain text only. No markdown, no bold, no tables, no emojis, no bullet symbols.
|
|
55
|
+
- Always try tools first. Only ask the user for input if the tools truly cannot answer.
|
|
56
|
+
- Before saying you cannot do something, scan all available tools and try any that could be relevant. Never give up after one failed attempt.
|
|
57
|
+
- When you genuinely cannot do something, say so in one short sentence. Do not give the user a wall of manual instructions — they know how to use GitHub's website.
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import os
|
|
3
|
+
import re
|
|
4
|
+
import subprocess
|
|
5
|
+
import urllib.error
|
|
6
|
+
import urllib.request
|
|
7
|
+
|
|
8
|
+
from strands import tool
|
|
9
|
+
|
|
10
|
+
LOCAL_REPO_PATH = os.getcwd()
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def _gql(query, variables={}):
|
|
14
|
+
token = os.environ["GITHUB_TOKEN"]
|
|
15
|
+
data = json.dumps({"query": query, "variables": variables}).encode()
|
|
16
|
+
req = urllib.request.Request("https://api.github.com/graphql", data=data, method="POST")
|
|
17
|
+
req.add_header("Authorization", f"Bearer {token}")
|
|
18
|
+
req.add_header("Content-Type", "application/json")
|
|
19
|
+
with urllib.request.urlopen(req) as r:
|
|
20
|
+
return json.loads(r.read())
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@tool
|
|
24
|
+
def set_issue_priority(owner: str, repo: str, issue_number: int, priority: str) -> str:
|
|
25
|
+
"""Set the Priority field on a GitHub issue. priority: urgent, high, medium, or low."""
|
|
26
|
+
p = priority.lower()
|
|
27
|
+
if p not in ("urgent", "high", "medium", "low"):
|
|
28
|
+
return "Error: priority must be urgent, high, medium, or low"
|
|
29
|
+
try:
|
|
30
|
+
lookup = _gql("""
|
|
31
|
+
query($owner: String!, $repo: String!, $num: Int!) {
|
|
32
|
+
repository(owner: $owner, name: $repo) { issue(number: $num) { id } }
|
|
33
|
+
organization(login: $owner) {
|
|
34
|
+
issueFields(first: 10) {
|
|
35
|
+
nodes { ... on IssueFieldSingleSelect { id name options { id name } } }
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}""", {"owner": owner, "repo": repo, "num": issue_number})
|
|
39
|
+
if "errors" in lookup:
|
|
40
|
+
return f"Error: {lookup['errors'][0]['message']}"
|
|
41
|
+
issue_id = lookup["data"]["repository"]["issue"]["id"]
|
|
42
|
+
pf = next((f for f in lookup["data"]["organization"]["issueFields"]["nodes"] if f.get("name") == "Priority"), None)
|
|
43
|
+
if not pf:
|
|
44
|
+
return "Priority field not found in org"
|
|
45
|
+
opt = next((o for o in pf["options"] if o["name"].lower() == p), None)
|
|
46
|
+
if not opt:
|
|
47
|
+
return f"Option '{p}' not found"
|
|
48
|
+
result = _gql("""
|
|
49
|
+
mutation($i: ID!, $f: ID!, $o: ID!) {
|
|
50
|
+
updateIssueFieldValue(input: {
|
|
51
|
+
issueId: $i
|
|
52
|
+
issueField: { fieldId: $f, singleSelectOptionId: $o }
|
|
53
|
+
}) { clientMutationId }
|
|
54
|
+
}""", {"i": issue_id, "f": pf["id"], "o": opt["id"]})
|
|
55
|
+
if "errors" in result:
|
|
56
|
+
return f"Error: {result['errors'][0]['message']}"
|
|
57
|
+
return f"Priority set to '{p}' on {owner}/{repo}#{issue_number}"
|
|
58
|
+
except urllib.error.HTTPError as e:
|
|
59
|
+
return f"HTTP {e.code}: {e.read().decode()}"
|
|
60
|
+
except Exception as e:
|
|
61
|
+
return f"Error: {e}"
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@tool
|
|
65
|
+
def list_issues_with_priorities(owner: str, repo: str, state: str = "OPEN") -> str:
|
|
66
|
+
"""List issues with their priority AND description in one call. Use this instead of list_issues whenever priorities matter. state: OPEN, CLOSED, or ALL."""
|
|
67
|
+
try:
|
|
68
|
+
result = _gql("""
|
|
69
|
+
query($owner: String!, $repo: String!, $state: [IssueState!]) {
|
|
70
|
+
repository(owner: $owner, name: $repo) {
|
|
71
|
+
issues(first: 100, states: $state, orderBy: {field: CREATED_AT, direction: DESC}) {
|
|
72
|
+
nodes {
|
|
73
|
+
number title body state
|
|
74
|
+
issueFieldValues(first: 5) {
|
|
75
|
+
nodes {
|
|
76
|
+
... on IssueFieldSingleSelectValue {
|
|
77
|
+
name
|
|
78
|
+
field { ... on IssueFieldSingleSelect { name } }
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}""", {"owner": owner, "repo": repo, "state": [state] if state != "ALL" else ["OPEN", "CLOSED"]})
|
|
86
|
+
if "errors" in result:
|
|
87
|
+
return f"Error: {result['errors'][0]['message']}"
|
|
88
|
+
lines = []
|
|
89
|
+
for issue in result["data"]["repository"]["issues"]["nodes"]:
|
|
90
|
+
priority = "not set"
|
|
91
|
+
for fv in issue.get("issueFieldValues", {}).get("nodes", []):
|
|
92
|
+
if fv.get("field", {}).get("name") == "Priority":
|
|
93
|
+
priority = fv.get("name", "not set").lower()
|
|
94
|
+
break
|
|
95
|
+
body = (issue.get("body") or "").strip().replace("\n", " ")[:150]
|
|
96
|
+
lines.append(f"#{issue['number']} [{issue['state']}] {issue['title']} | Priority: {priority} | {body}")
|
|
97
|
+
return "\n".join(lines) or "No issues found"
|
|
98
|
+
except urllib.error.HTTPError as e:
|
|
99
|
+
return f"HTTP {e.code}: {e.read().decode()}"
|
|
100
|
+
except Exception as e:
|
|
101
|
+
return f"Error: {e}"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
@tool
|
|
105
|
+
def list_board_status(owner: str, project_number: int) -> str:
|
|
106
|
+
"""List all project board items grouped by their Status (Todo / In Progress / Done). Use this when the user asks about board status, what is in progress, or what is done. Never use projects_list for this — it overflows."""
|
|
107
|
+
try:
|
|
108
|
+
result = _gql("""
|
|
109
|
+
query($owner: String!, $num: Int!) {
|
|
110
|
+
organization(login: $owner) {
|
|
111
|
+
projectV2(number: $num) {
|
|
112
|
+
items(first: 100) {
|
|
113
|
+
nodes {
|
|
114
|
+
content {
|
|
115
|
+
... on Issue { number title state }
|
|
116
|
+
}
|
|
117
|
+
fieldValues(first: 10) {
|
|
118
|
+
nodes {
|
|
119
|
+
... on ProjectV2ItemFieldSingleSelectValue {
|
|
120
|
+
name
|
|
121
|
+
field { ... on ProjectV2SingleSelectField { name } }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}""", {"owner": owner, "num": project_number})
|
|
130
|
+
if "errors" in result:
|
|
131
|
+
return f"Error: {result['errors'][0]['message']}"
|
|
132
|
+
buckets: dict = {}
|
|
133
|
+
for item in result["data"]["organization"]["projectV2"]["items"]["nodes"]:
|
|
134
|
+
content = item.get("content") or {}
|
|
135
|
+
if not content.get("number"):
|
|
136
|
+
continue
|
|
137
|
+
number = content["number"]
|
|
138
|
+
title = content.get("title", "")
|
|
139
|
+
status = "No Status"
|
|
140
|
+
for fv in item.get("fieldValues", {}).get("nodes", []):
|
|
141
|
+
if fv.get("field", {}).get("name") == "Status":
|
|
142
|
+
status = fv.get("name", "No Status")
|
|
143
|
+
break
|
|
144
|
+
buckets.setdefault(status, []).append(f" #{number} {title}")
|
|
145
|
+
lines = []
|
|
146
|
+
for status, items in buckets.items():
|
|
147
|
+
lines.append(f"{status} ({len(items)}):")
|
|
148
|
+
lines.extend(items)
|
|
149
|
+
lines.append("")
|
|
150
|
+
return "\n".join(lines) or "No items found"
|
|
151
|
+
except urllib.error.HTTPError as e:
|
|
152
|
+
return f"HTTP {e.code}: {e.read().decode()}"
|
|
153
|
+
except Exception as e:
|
|
154
|
+
return f"Error: {e}"
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@tool
|
|
158
|
+
def read_local_file(path: str) -> str:
|
|
159
|
+
"""Read a file from the local repository. Path is relative to the repo root."""
|
|
160
|
+
full_path = os.path.normpath(os.path.join(LOCAL_REPO_PATH, path))
|
|
161
|
+
if not full_path.startswith(LOCAL_REPO_PATH):
|
|
162
|
+
return "Error: path outside repo root"
|
|
163
|
+
try:
|
|
164
|
+
with open(full_path) as f:
|
|
165
|
+
return f.read()
|
|
166
|
+
except FileNotFoundError:
|
|
167
|
+
return f"File not found: {path}"
|
|
168
|
+
except Exception as e:
|
|
169
|
+
return f"Error reading file: {e}"
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
@tool
|
|
173
|
+
def list_local_files(path: str = ".") -> str:
|
|
174
|
+
"""List files and directories in the local repository. Path is relative to the repo root."""
|
|
175
|
+
full_path = os.path.normpath(os.path.join(LOCAL_REPO_PATH, path))
|
|
176
|
+
if not full_path.startswith(LOCAL_REPO_PATH):
|
|
177
|
+
return "Error: path outside repo root"
|
|
178
|
+
try:
|
|
179
|
+
entries = []
|
|
180
|
+
for entry in sorted(os.scandir(full_path), key=lambda e: (not e.is_dir(), e.name)):
|
|
181
|
+
prefix = "/" if entry.is_dir() else ""
|
|
182
|
+
entries.append(f"{entry.name}{prefix}")
|
|
183
|
+
return "\n".join(entries)
|
|
184
|
+
except FileNotFoundError:
|
|
185
|
+
return f"Directory not found: {path}"
|
|
186
|
+
except Exception as e:
|
|
187
|
+
return f"Error listing files: {e}"
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@tool
|
|
191
|
+
def detect_current_repo() -> str:
|
|
192
|
+
"""Detect the GitHub repository the user is currently working in, based on the local git remote origin URL."""
|
|
193
|
+
try:
|
|
194
|
+
url = subprocess.check_output(
|
|
195
|
+
["git", "remote", "get-url", "origin"],
|
|
196
|
+
stderr=subprocess.DEVNULL,
|
|
197
|
+
cwd=os.getcwd(),
|
|
198
|
+
).decode().strip()
|
|
199
|
+
match = re.search(r"github\.com[:/](.+?)(?:\.git)?$", url)
|
|
200
|
+
if match:
|
|
201
|
+
return match.group(1)
|
|
202
|
+
except Exception:
|
|
203
|
+
pass
|
|
204
|
+
return "No GitHub remote detected"
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
local_tools = [
|
|
208
|
+
set_issue_priority,
|
|
209
|
+
list_issues_with_priorities,
|
|
210
|
+
list_board_status,
|
|
211
|
+
detect_current_repo,
|
|
212
|
+
read_local_file,
|
|
213
|
+
list_local_files,
|
|
214
|
+
]
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: github-mcp-agent
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A GitHub AI agent powered by AWS Bedrock and MCP
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: anthropic
|
|
7
|
+
Requires-Dist: boto3
|
|
8
|
+
Requires-Dist: click
|
|
9
|
+
Requires-Dist: litellm
|
|
10
|
+
Requires-Dist: mcp
|
|
11
|
+
Requires-Dist: python-dotenv
|
|
12
|
+
Requires-Dist: questionary
|
|
13
|
+
Requires-Dist: rich
|
|
14
|
+
Requires-Dist: strands-agents
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
github_mcp_agent/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
2
|
+
github_mcp_agent/agent.py,sha256=vbMm-pGsYOD-z0TeUIfczdbXPrdi62jK-GK1ZwesFog,3520
|
|
3
|
+
github_mcp_agent/cli.py,sha256=0phwP9dRWzKzOzc6R-uZZQIWpwFtrJUammhe4Jyc3_k,6342
|
|
4
|
+
github_mcp_agent/setup_wizard.py,sha256=RD0az_GsXI8zzB2_GQeNsO9JNoUGYdMc9isguTf8fys,4640
|
|
5
|
+
github_mcp_agent/system_prompt.txt,sha256=K67KFba4IzsYd9priF-7ORFTRvWB0wqonlN4p3H01Do,4532
|
|
6
|
+
github_mcp_agent/tools.py,sha256=xg-EglraCAHamEkYeNcavdNc2UyGMFamNr1F6BTrGtY,8602
|
|
7
|
+
github_mcp_agent/providers/__init__.py,sha256=xdDXQVtKXE5A_cKjh5w9wD8tJBiZ7sJnNfYYng7MbTw,907
|
|
8
|
+
github_mcp_agent/providers/anthropic.py,sha256=DvJpbuMghY871zGbSuQ5NHiRQl0HZj_TedZU7XYMYVU,1022
|
|
9
|
+
github_mcp_agent/providers/bedrock.py,sha256=-HJFM0e-TgnPxEErkeaV7B6CBiutTKDklEIGNdMYRxE,4619
|
|
10
|
+
github_mcp_agent/providers/copilot.py,sha256=kmFsE7V6vxRn0vIQfRCw5j48SVi2kUnjblEdXWXqzUU,1208
|
|
11
|
+
github_mcp_agent/providers/gemini.py,sha256=6h2A1374mKBrjb9flJh0gmjhxjcmbEOv1_Iov1OCutM,980
|
|
12
|
+
github_mcp_agent/providers/ollama.py,sha256=2Qtx6crnH7FwnpN_nlej9kl5_tO4UC26vGexnpBHVU4,2633
|
|
13
|
+
github_mcp_agent/providers/openai.py,sha256=JpjlbccDuq7btdudA5PBbcjYVv5tyNCAGbVHQ0U2Kr0,937
|
|
14
|
+
github_mcp_agent-0.1.0.dist-info/METADATA,sha256=l6XFCYlQbs4YDtlGztiB-uMuUwOw2c_pNCVSNgEibUc,357
|
|
15
|
+
github_mcp_agent-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
16
|
+
github_mcp_agent-0.1.0.dist-info/entry_points.txt,sha256=ubnAt2X37Z0pEbZWMoS2rLMHIsI-1vtqS433IY6aZAk,59
|
|
17
|
+
github_mcp_agent-0.1.0.dist-info/RECORD,,
|