agentcode-cli 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mcp_client.py ADDED
@@ -0,0 +1,194 @@
1
+ """
2
+ AgentCode — MCP (Model Context Protocol) client.
3
+
4
+ Connects to MCP servers defined in .agentcode/mcp.json and exposes
5
+ their tools to the agent loop alongside the built-in tools.
6
+
7
+ Config format (.agentcode/mcp.json or ~/.agentcode/mcp.json):
8
+ {
9
+ "mcpServers": {
10
+ "filesystem": {
11
+ "command": "npx",
12
+ "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"]
13
+ },
14
+ "github": {
15
+ "command": "npx",
16
+ "args": ["-y", "@modelcontextprotocol/server-github"],
17
+ "env": {"GITHUB_TOKEN": "ghp_..."}
18
+ }
19
+ }
20
+ }
21
+
22
+ Tools are exposed as mcp__<server>__<tool> (e.g. mcp__filesystem__read_file).
23
+ """
24
+
25
+ import asyncio
26
+ import json
27
+ import os
28
+ import threading
29
+ from contextlib import AsyncExitStack
30
+ from pathlib import Path
31
+
32
+ from rich.console import Console
33
+
34
+ console = Console()
35
+
36
+
37
+ class _ServerConnection:
38
+ """Holds a live stdio connection to one MCP server."""
39
+
40
+ def __init__(self, name: str, config: dict):
41
+ self.name = name
42
+ self.config = config
43
+ self._session = None
44
+ self._stack: AsyncExitStack | None = None
45
+
46
+ async def start(self):
47
+ from mcp import ClientSession, StdioServerParameters
48
+ from mcp.client.stdio import stdio_client
49
+
50
+ env = os.environ.copy()
51
+ env.update(self.config.get("env") or {})
52
+
53
+ params = StdioServerParameters(
54
+ command=self.config["command"],
55
+ args=self.config.get("args", []),
56
+ env=env,
57
+ )
58
+ self._stack = AsyncExitStack()
59
+ read, write = await self._stack.enter_async_context(stdio_client(params))
60
+ self._session = await self._stack.enter_async_context(ClientSession(read, write))
61
+ await self._session.initialize()
62
+
63
+ async def stop(self):
64
+ if self._stack:
65
+ await self._stack.aclose()
66
+
67
+ async def list_tools(self):
68
+ return await self._session.list_tools()
69
+
70
+ async def call_tool(self, name: str, args: dict):
71
+ return await self._session.call_tool(name, args)
72
+
73
+
74
+ class MCPManager:
75
+ """
76
+ Manages connections to one or more MCP servers.
77
+
78
+ Runs a persistent asyncio event loop in a background thread so the
79
+ synchronous agent loop can call MCP tools without restructuring.
80
+ """
81
+
82
+ def __init__(self):
83
+ self._loop = asyncio.new_event_loop()
84
+ self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
85
+ self._thread.start()
86
+ self._servers: dict[str, _ServerConnection] = {}
87
+ self._tool_map: dict[str, tuple[str, str]] = {} # mcp__server__tool -> (server, tool)
88
+
89
+ def _run(self, coro, timeout: int = 30):
90
+ future = asyncio.run_coroutine_threadsafe(coro, self._loop)
91
+ return future.result(timeout=timeout)
92
+
93
+ def connect(self, name: str, config: dict) -> None:
94
+ conn = _ServerConnection(name, config)
95
+ self._run(conn.start())
96
+ self._servers[name] = conn
97
+ tools = self._run(conn.list_tools())
98
+ for tool in tools.tools:
99
+ key = f"mcp__{name}__{tool.name}"
100
+ self._tool_map[key] = (name, tool.name)
101
+
102
+ def is_mcp_tool(self, tool_name: str) -> bool:
103
+ return tool_name in self._tool_map
104
+
105
+ def get_tool_definitions(self) -> list[dict]:
106
+ """Return OpenAI-format tool definitions for all connected MCP tools."""
107
+ defs = []
108
+ for server_name, conn in self._servers.items():
109
+ tools = self._run(conn.list_tools())
110
+ for tool in tools.tools:
111
+ key = f"mcp__{server_name}__{tool.name}"
112
+ schema = tool.inputSchema if hasattr(tool, "inputSchema") else {
113
+ "type": "object", "properties": {}
114
+ }
115
+ defs.append({
116
+ "type": "function",
117
+ "function": {
118
+ "name": key,
119
+ "description": f"[{server_name}] {tool.description or tool.name}",
120
+ "parameters": schema,
121
+ },
122
+ })
123
+ return defs
124
+
125
+ def call_tool(self, tool_name: str, args: dict) -> str:
126
+ if tool_name not in self._tool_map:
127
+ return f"Error: Unknown MCP tool '{tool_name}'"
128
+ server_name, original_name = self._tool_map[tool_name]
129
+ conn = self._servers[server_name]
130
+ try:
131
+ result = self._run(conn.call_tool(original_name, args))
132
+ except Exception as e:
133
+ return f"Error calling MCP tool '{tool_name}': {e}"
134
+
135
+ if not result.content:
136
+ return "Tool returned no content."
137
+ parts = []
138
+ for block in result.content:
139
+ if hasattr(block, "text"):
140
+ parts.append(block.text)
141
+ elif hasattr(block, "data"):
142
+ parts.append(f"[binary data: {len(block.data)} bytes]")
143
+ return "\n".join(parts) or "Tool returned no content."
144
+
145
+ def server_names(self) -> list[str]:
146
+ return list(self._servers.keys())
147
+
148
+ def shutdown(self):
149
+ for conn in self._servers.values():
150
+ try:
151
+ self._run(conn.stop(), timeout=5)
152
+ except Exception:
153
+ pass
154
+ self._loop.call_soon_threadsafe(self._loop.stop)
155
+
156
+
157
+ # ── Config loading ─────────────────────────────────────────────────────────────
158
+
159
+ def load_mcp_config(project_dir: str) -> dict:
160
+ config: dict = {}
161
+ for path in [
162
+ Path.home() / ".agentcode" / "mcp.json",
163
+ Path(project_dir) / ".agentcode" / "mcp.json",
164
+ ]:
165
+ if path.exists():
166
+ try:
167
+ data = json.loads(path.read_text())
168
+ config.update(data.get("mcpServers", {}))
169
+ except Exception as e:
170
+ console.print(f"[warning]⚠ Failed to parse MCP config {path}: {e}[/warning]")
171
+ return config
172
+
173
+
174
+ def create_mcp_manager(project_dir: str) -> "MCPManager | None":
175
+ """Load config and connect to all configured MCP servers. Returns None if none configured."""
176
+ try:
177
+ import mcp # noqa: check available
178
+ except ImportError:
179
+ return None
180
+
181
+ config = load_mcp_config(project_dir)
182
+ if not config:
183
+ return None
184
+
185
+ manager = MCPManager()
186
+ for name, server_config in config.items():
187
+ try:
188
+ manager.connect(name, server_config)
189
+ console.print(f"[dim] MCP: connected to [bold]{name}[/bold] "
190
+ f"({len([k for k in manager._tool_map if k.startswith(f'mcp__{name}__')])} tools)[/dim]")
191
+ except Exception as e:
192
+ console.print(f"[warning]⚠ MCP server '{name}' failed: {e}[/warning]")
193
+
194
+ return manager if manager._servers else None
router.py ADDED
@@ -0,0 +1,298 @@
1
+ """
2
+ AgentCode - Cost-aware model router.
3
+
4
+ Automatically picks the cheapest model that can handle the task.
5
+ Simple questions get a fast, cheap model. Complex multi-file refactors
6
+ get a powerful, expensive one.
7
+ """
8
+
9
+ import re
10
+ from dataclasses import dataclass, field
11
+ from rich.console import Console
12
+
13
+ console = Console()
14
+
15
+
16
+ # ── Model Tiers ───────────────────────────────────────────────────────────────
17
+
18
+ @dataclass
19
+ class ModelTier:
20
+ """A model with its capabilities and pricing."""
21
+ name: str # LiteLLM model string
22
+ tier: str # "light", "medium", "heavy"
23
+ label: str # Human-readable label
24
+ input_cost_per_mtok: float # $ per 1M input tokens
25
+ output_cost_per_mtok: float # $ per 1M output tokens
26
+
27
+
28
+ # ── Default Model Configs ─────────────────────────────────────────────────────
29
+ # Users can override these via AGENTCODE.md or environment variables.
30
+ # Costs are approximate as of mid-2026.
31
+
32
+ ANTHROPIC_TIERS = [
33
+ ModelTier("claude-haiku-4-5-20251001", "light", "Haiku 4.5", 0.80, 4.00),
34
+ ModelTier("claude-sonnet-4-6", "medium", "Sonnet 4.6", 3.00, 15.00),
35
+ ModelTier("claude-opus-4-6", "heavy", "Opus 4.6", 15.00, 75.00),
36
+ ]
37
+
38
+ OPENAI_TIERS = [
39
+ ModelTier("gpt-4o-mini", "light", "GPT-4o Mini", 0.15, 0.60),
40
+ ModelTier("gpt-4o", "medium", "GPT-4o", 2.50, 10.00),
41
+ ModelTier("gpt-5.5", "heavy", "GPT-5.5", 15.00, 60.00),
42
+ ]
43
+
44
+ GEMINI_TIERS = [
45
+ ModelTier("gemini/gemini-2.0-flash", "light", "Gemini 2.0 Flash", 0.10, 0.40),
46
+ ModelTier("gemini/gemini-2.5-flash", "medium", "Gemini 2.5 Flash", 0.25, 1.00),
47
+ ModelTier("gemini/gemini-2.5-pro", "heavy", "Gemini 2.5 Pro", 1.25, 10.00),
48
+ ]
49
+
50
+ # Provider configs keyed by prefix
51
+ PROVIDER_TIERS = {
52
+ "anthropic": ANTHROPIC_TIERS,
53
+ "openai": OPENAI_TIERS,
54
+ "gemini": GEMINI_TIERS,
55
+ }
56
+
57
+
58
+ # ── Task Complexity Classification ────────────────────────────────────────────
59
+
60
+ # Patterns that signal increasing complexity
61
+ LIGHT_PATTERNS = [
62
+ r"\b(explain|what is|what are|what does|how does|describe|list|show|tell me)\b",
63
+ r"\b(read|cat|view|print|display|show me|open)\b",
64
+ r"\b(git status|git log|git diff)\b",
65
+ r"\b(hello|hi|hey|thanks|thank you)\b",
66
+ r"\b(format|lint|indent|rename)\b",
67
+ r"\b(typo|spelling|grammar|comment)\b",
68
+ ]
69
+
70
+ MEDIUM_PATTERNS = [
71
+ r"\b(write|create|add|implement|build|make|generate)\b",
72
+ r"\b(fix|bug|error|debug|broken|failing|issue)\b",
73
+ r"\b(test|unit test|pytest|spec)\b",
74
+ r"\b(function|class|method|endpoint|route|api)\b",
75
+ r"\b(edit|update|change|modify)\b",
76
+ r"\b(install|setup|configure|deploy)\b",
77
+ ]
78
+
79
+ HEAVY_PATTERNS = [
80
+ r"\b(refactor|restructure|redesign|rearchitect|overhaul)\b",
81
+ r"\b(migrate|migration|upgrade|convert|port)\b",
82
+ r"\b(entire|whole|all files|every file|codebase|full)\b",
83
+ r"\b(optimize|performance|bottleneck|profil)\b",
84
+ r"\b(security|vulnerability|audit|review all)\b",
85
+ r"\b(multi.?file|across files|multiple files)\b",
86
+ r"\b(design pattern|architecture|system design)\b",
87
+ r"\b(from scratch|ground up|complete|comprehensive)\b",
88
+ ]
89
+
90
+
91
+ def classify_complexity(user_input: str) -> str:
92
+ """
93
+ Classify task complexity as 'light', 'medium', or 'heavy'.
94
+
95
+ Uses pattern matching on the user's prompt. This is intentionally
96
+ simple and transparent — users can see exactly why a model was chosen.
97
+ """
98
+ text = user_input.lower().strip()
99
+
100
+ # Score each tier
101
+ heavy_score = sum(1 for p in HEAVY_PATTERNS if re.search(p, text, re.IGNORECASE))
102
+ medium_score = sum(1 for p in MEDIUM_PATTERNS if re.search(p, text, re.IGNORECASE))
103
+ light_score = sum(1 for p in LIGHT_PATTERNS if re.search(p, text, re.IGNORECASE))
104
+
105
+ # Length heuristic: very long prompts are usually complex
106
+ if len(text) > 500:
107
+ heavy_score += 2
108
+ elif len(text) > 200:
109
+ medium_score += 1
110
+
111
+ # Multiple file references suggest complexity
112
+ file_refs = len(re.findall(r'\.\w{1,4}\b', text)) # .py, .js, .tsx, etc.
113
+ if file_refs >= 3:
114
+ heavy_score += 2
115
+ elif file_refs >= 2:
116
+ medium_score += 1
117
+
118
+ # Decision logic
119
+ if heavy_score >= 2:
120
+ return "heavy"
121
+ elif heavy_score >= 1 and medium_score >= 1:
122
+ return "heavy"
123
+ elif medium_score >= 1:
124
+ return "medium"
125
+ else:
126
+ return "light"
127
+
128
+
129
+ # ── Router ────────────────────────────────────────────────────────────────────
130
+
131
+ @dataclass
132
+ class CostTracker:
133
+ """Track cumulative costs across a session."""
134
+ total_input_tokens: int = 0
135
+ total_output_tokens: int = 0
136
+ total_cost: float = 0.0
137
+ requests: list[dict] = field(default_factory=list)
138
+ last_turn_input: int = 0
139
+ last_turn_output: int = 0
140
+ last_turn_cost: float = 0.0
141
+
142
+ def begin_turn(self):
143
+ """Reset per-turn counters at the start of each agent loop call."""
144
+ self.last_turn_input = 0
145
+ self.last_turn_output = 0
146
+ self.last_turn_cost = 0.0
147
+
148
+ def record(self, model: str, input_tokens: int, output_tokens: int, cost: float):
149
+ self.total_input_tokens += input_tokens
150
+ self.total_output_tokens += output_tokens
151
+ self.total_cost += cost
152
+ self.last_turn_input += input_tokens
153
+ self.last_turn_output += output_tokens
154
+ self.last_turn_cost += cost
155
+ self.requests.append({
156
+ "model": model,
157
+ "input_tokens": input_tokens,
158
+ "output_tokens": output_tokens,
159
+ "cost": cost,
160
+ })
161
+
162
+ def summary(self) -> str:
163
+ if not self.requests:
164
+ return "No requests yet."
165
+ lines = [
166
+ f"Session total: ${self.total_cost:.4f} "
167
+ f"({self.total_input_tokens:,} in / {self.total_output_tokens:,} out)",
168
+ f"Requests: {len(self.requests)}",
169
+ ]
170
+ # Show last 5 requests
171
+ for r in self.requests[-5:]:
172
+ lines.append(
173
+ f" {r['model']}: ${r['cost']:.4f} "
174
+ f"({r['input_tokens']:,} in / {r['output_tokens']:,} out)"
175
+ )
176
+ if len(self.requests) > 5:
177
+ lines.append(f" ... and {len(self.requests) - 5} earlier requests")
178
+ return "\n".join(lines)
179
+
180
+
181
+ @dataclass
182
+ class ModelRouter:
183
+ """
184
+ Cost-aware model router.
185
+
186
+ Automatically selects the cheapest model that can handle the task.
187
+ Falls back to the default model if no tier config is available.
188
+ """
189
+ provider: str = "anthropic" # "anthropic" or "openai"
190
+ default_model: str = "claude-sonnet-4-6"
191
+ enabled: bool = True # False = always use default_model
192
+ cost_tracker: CostTracker = field(default_factory=CostTracker)
193
+
194
+ # Allow per-tier overrides
195
+ light_model: str | None = None
196
+ medium_model: str | None = None
197
+ heavy_model: str | None = None
198
+
199
+ def get_tiers(self) -> list[ModelTier]:
200
+ """Get the tier config for the current provider."""
201
+ return PROVIDER_TIERS.get(self.provider, [])
202
+
203
+ def detect_provider(self, model_string: str) -> str:
204
+ """Detect provider from a model string."""
205
+ m = model_string.lower()
206
+ if "claude" in m or "anthropic" in m:
207
+ return "anthropic"
208
+ elif "gpt" in m or "openai" in m or "o1" in m or "o3" in m:
209
+ return "openai"
210
+ elif "gemini" in m or "google" in m:
211
+ return "gemini"
212
+ else:
213
+ return "unknown"
214
+
215
+ def route(self, user_input: str) -> tuple[str, str, str]:
216
+ """
217
+ Pick the best model for this task.
218
+
219
+ Returns:
220
+ (model_string, tier, reason) — the model to use, its tier,
221
+ and a human-readable explanation of why it was chosen.
222
+ """
223
+ if not self.enabled:
224
+ return self.default_model, "default", "routing disabled"
225
+
226
+ complexity = classify_complexity(user_input)
227
+
228
+ # Check for per-tier overrides first
229
+ if complexity == "light" and self.light_model:
230
+ return self.light_model, "light", self._reason(user_input, complexity)
231
+ elif complexity == "medium" and self.medium_model:
232
+ return self.medium_model, "medium", self._reason(user_input, complexity)
233
+ elif complexity == "heavy" and self.heavy_model:
234
+ return self.heavy_model, "heavy", self._reason(user_input, complexity)
235
+
236
+ # Use provider tier defaults
237
+ tiers = self.get_tiers()
238
+ if not tiers:
239
+ return self.default_model, "default", "no tier config for provider"
240
+
241
+ tier_map = {t.tier: t for t in tiers}
242
+ tier = tier_map.get(complexity)
243
+ if tier:
244
+ return tier.name, complexity, self._reason(user_input, complexity)
245
+
246
+ return self.default_model, "default", "no matching tier"
247
+
248
+ def _reason(self, user_input: str, complexity: str) -> str:
249
+ """Generate a human-readable reason for the routing decision."""
250
+ reasons = {
251
+ "light": "simple query — using fast, cheap model",
252
+ "medium": "standard coding task — using balanced model",
253
+ "heavy": "complex multi-step task — using powerful model",
254
+ }
255
+ return reasons.get(complexity, "unknown complexity")
256
+
257
+ def estimate_cost(self, model: str, input_tokens: int, output_tokens: int) -> float:
258
+ """Estimate cost for a request."""
259
+ # Search all tiers for this model
260
+ for tiers in PROVIDER_TIERS.values():
261
+ for tier in tiers:
262
+ if tier.name == model:
263
+ cost = (
264
+ (input_tokens / 1_000_000) * tier.input_cost_per_mtok
265
+ + (output_tokens / 1_000_000) * tier.output_cost_per_mtok
266
+ )
267
+ return cost
268
+ return 0.0 # Unknown model, can't estimate
269
+
270
+ def get_tier_info(self, model: str) -> ModelTier | None:
271
+ """Get tier info for a model."""
272
+ for tiers in PROVIDER_TIERS.values():
273
+ for tier in tiers:
274
+ if tier.name == model:
275
+ return tier
276
+ return None
277
+
278
+
279
+ def display_routing_decision(model: str, tier: str, reason: str, router: ModelRouter):
280
+ """Show the user which model was selected and why."""
281
+ tier_colors = {
282
+ "light": "green",
283
+ "medium": "yellow",
284
+ "heavy": "red",
285
+ "default": "cyan",
286
+ }
287
+ color = tier_colors.get(tier, "white")
288
+
289
+ tier_info = router.get_tier_info(model)
290
+ cost_hint = ""
291
+ if tier_info:
292
+ cost_hint = f" [dim](${tier_info.input_cost_per_mtok:.2f}/${tier_info.output_cost_per_mtok:.2f} per 1M tok)[/dim]"
293
+
294
+ console.print(
295
+ f" [{color}]⚡ {tier.upper()}[/{color}] → "
296
+ f"[bold]{model}[/bold]{cost_hint} "
297
+ f"[dim]({reason})[/dim]"
298
+ )
settings.py ADDED
@@ -0,0 +1,185 @@
1
+ """AgentCode settings — load and merge configuration from global, project, and CLI."""
2
+
3
+ import json
4
+ from dataclasses import dataclass, field
5
+ from pathlib import Path
6
+
7
+
8
+ DEFAULT_AUTO_APPROVE = [
9
+ "read_file", "list_directory", "search_files", "search_text",
10
+ "git_status", "git_log", "git_diff", "spawn_subagents",
11
+ ]
12
+
13
+
14
+ @dataclass
15
+ class PermissionsSettings:
16
+ auto_approve_all: bool = False
17
+ auto_approve: list[str] = field(default_factory=lambda: list(DEFAULT_AUTO_APPROVE))
18
+ deny: list[str] = field(default_factory=list)
19
+
20
+
21
+ @dataclass
22
+ class ModelSettings:
23
+ default: str = "claude-sonnet-4-6"
24
+ routing: bool = True
25
+ light: str | None = None
26
+ medium: str | None = None
27
+ heavy: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class LimitsSettings:
32
+ max_file_size: int = 1_000_000
33
+ max_output: int = 50_000
34
+ max_search_results: int = 100
35
+ max_iterations: int = 25
36
+
37
+
38
+ @dataclass
39
+ class Settings:
40
+ permissions: PermissionsSettings = field(default_factory=PermissionsSettings)
41
+ model: ModelSettings = field(default_factory=ModelSettings)
42
+ limits: LimitsSettings = field(default_factory=LimitsSettings)
43
+ hooks: dict[str, str] = field(default_factory=dict)
44
+
45
+
46
+ # ── Merge helpers ─────────────────────────────────────────────────────────────
47
+
48
+ def _deep_merge(base: dict, override: dict) -> dict:
49
+ """Recursively merge override into base, returning a new dict."""
50
+ result = base.copy()
51
+ for key, val in override.items():
52
+ if key in result and isinstance(result[key], dict) and isinstance(val, dict):
53
+ result[key] = _deep_merge(result[key], val)
54
+ else:
55
+ result[key] = val
56
+ return result
57
+
58
+
59
+ def _load_json(path: Path) -> dict:
60
+ try:
61
+ return json.loads(path.read_text())
62
+ except Exception:
63
+ return {}
64
+
65
+
66
+ def _dict_to_settings(d: dict) -> "Settings":
67
+ """Construct a Settings from a merged raw dict, filling in defaults for missing keys."""
68
+ perms_d = d.get("permissions", {})
69
+ model_d = d.get("model", {})
70
+ limits_d = d.get("limits", {})
71
+ hooks_d = d.get("hooks", {})
72
+
73
+ return Settings(
74
+ permissions=PermissionsSettings(
75
+ auto_approve_all=bool(perms_d.get("auto_approve_all", False)),
76
+ auto_approve=list(perms_d.get("auto_approve", DEFAULT_AUTO_APPROVE)),
77
+ deny=list(perms_d.get("deny", [])),
78
+ ),
79
+ model=ModelSettings(
80
+ default=str(model_d.get("default", "claude-sonnet-4-6")),
81
+ routing=bool(model_d.get("routing", True)),
82
+ light=model_d.get("light") or None,
83
+ medium=model_d.get("medium") or None,
84
+ heavy=model_d.get("heavy") or None,
85
+ ),
86
+ limits=LimitsSettings(
87
+ max_file_size=int(limits_d.get("max_file_size", 1_000_000)),
88
+ max_output=int(limits_d.get("max_output", 50_000)),
89
+ max_search_results=int(limits_d.get("max_search_results", 100)),
90
+ max_iterations=int(limits_d.get("max_iterations", 25)),
91
+ ),
92
+ hooks=dict(hooks_d),
93
+ )
94
+
95
+
96
+ # ── Public API ────────────────────────────────────────────────────────────────
97
+
98
+ def load_settings(project_dir: str, cli_overrides: dict | None = None) -> "Settings":
99
+ """
100
+ Load settings by merging (later layers override earlier ones):
101
+ 1. Built-in defaults
102
+ 2. ~/.agentcode/settings.json (global)
103
+ 3. <project>/.agentcode/settings.json (project)
104
+ 4. cli_overrides (CLI flags, highest priority)
105
+ """
106
+ merged: dict = {}
107
+
108
+ global_path = Path.home() / ".agentcode" / "settings.json"
109
+ if global_path.exists():
110
+ merged = _deep_merge(merged, _load_json(global_path))
111
+
112
+ project_path = Path(project_dir) / ".agentcode" / "settings.json"
113
+ if project_path.exists():
114
+ merged = _deep_merge(merged, _load_json(project_path))
115
+
116
+ if cli_overrides:
117
+ merged = _deep_merge(merged, cli_overrides)
118
+
119
+ return _dict_to_settings(merged)
120
+
121
+
122
+ def settings_sources(project_dir: str) -> dict[str, str | None]:
123
+ """Return paths to settings files that currently exist on disk."""
124
+ global_path = Path.home() / ".agentcode" / "settings.json"
125
+ project_path = Path(project_dir) / ".agentcode" / "settings.json"
126
+ return {
127
+ "global": str(global_path) if global_path.exists() else None,
128
+ "project": str(project_path) if project_path.exists() else None,
129
+ }
130
+
131
+
132
+ # ── Starter template ──────────────────────────────────────────────────────────
133
+
134
+ STARTER_SETTINGS: dict = {
135
+ "permissions": {
136
+ "auto_approve_all": False,
137
+ "auto_approve": [
138
+ "read_file", "list_directory", "search_files", "search_text",
139
+ "git_status", "git_log", "git_diff", "spawn_subagents"
140
+ ],
141
+ "deny": []
142
+ },
143
+ "model": {
144
+ "default": "claude-sonnet-4-6",
145
+ "routing": True,
146
+ "light": None,
147
+ "medium": None,
148
+ "heavy": None
149
+ },
150
+ "limits": {
151
+ "max_file_size": 1000000,
152
+ "max_output": 50000,
153
+ "max_search_results": 100,
154
+ "max_iterations": 25
155
+ },
156
+ "hooks": {}
157
+ }
158
+
159
+ SETTINGS_HELP = """\
160
+ permissions.auto_approve_all true to skip all permission prompts (like --auto-approve)
161
+ permissions.auto_approve tools that run without asking (default: read-only tools)
162
+ permissions.deny tools that are always blocked, regardless of other settings
163
+ model.default default LLM model string (e.g. "claude-sonnet-4-6")
164
+ model.routing enable cost-aware model routing (true/false)
165
+ model.light/medium/heavy override the model used for each routing tier
166
+ limits.max_file_size max bytes for read_file (default: 1,000,000)
167
+ limits.max_output max chars for tool output truncation (default: 50,000)
168
+ limits.max_search_results cap on results from search tools (default: 100)
169
+ limits.max_iterations max tool-call loops per turn (default: 25)
170
+ hooks shell commands run before/after tool calls
171
+ keys: pre_<tool>, post_<tool>, pre_tool, post_tool\
172
+ """
173
+
174
+
175
+ def generate_starter_settings(project_dir: str) -> tuple[Path, bool]:
176
+ """
177
+ Write a starter settings.json to <project>/.agentcode/settings.json.
178
+ Returns (path, created): created=False if the file already existed.
179
+ """
180
+ path = Path(project_dir) / ".agentcode" / "settings.json"
181
+ existed = path.exists()
182
+ if not existed:
183
+ path.parent.mkdir(exist_ok=True)
184
+ path.write_text(json.dumps(STARTER_SETTINGS, indent=2))
185
+ return path, not existed