ata-coder 2.4.2__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.
- ata_coder/__init__.py +1 -0
- ata_coder/agent.py +874 -0
- ata_coder/agent_compact.py +190 -0
- ata_coder/agent_controller.py +218 -0
- ata_coder/agent_extension.py +69 -0
- ata_coder/agent_routing.py +105 -0
- ata_coder/agent_subsystems.py +72 -0
- ata_coder/agent_tools.py +318 -0
- ata_coder/agent_undo.py +63 -0
- ata_coder/anthropic_client.py +465 -0
- ata_coder/change_tracker.py +368 -0
- ata_coder/clawd_integration.py +574 -0
- ata_coder/commands/__init__.py +128 -0
- ata_coder/commands/_core.py +184 -0
- ata_coder/commands/_safety.py +95 -0
- ata_coder/commands/_settings.py +241 -0
- ata_coder/commands/_workflow.py +451 -0
- ata_coder/commands.py +974 -0
- ata_coder/config.py +257 -0
- ata_coder/core/__init__.py +35 -0
- ata_coder/core/events.py +73 -0
- ata_coder/core/queue.py +85 -0
- ata_coder/core/state.py +17 -0
- ata_coder/event_queue.py +5 -0
- ata_coder/extension.py +654 -0
- ata_coder/extensions/__init__.py +1 -0
- ata_coder/extensions/hello_skill.py +47 -0
- ata_coder/fool_proof.py +295 -0
- ata_coder/git_workflow.py +371 -0
- ata_coder/gui.py +511 -0
- ata_coder/llm_client.py +543 -0
- ata_coder/main.py +814 -0
- ata_coder/mcp_client.py +1095 -0
- ata_coder/memory.py +539 -0
- ata_coder/model_registry.py +134 -0
- ata_coder/model_router.py +105 -0
- ata_coder/permissions.py +274 -0
- ata_coder/privilege.py +464 -0
- ata_coder/project.py +273 -0
- ata_coder/prompt_template.py +423 -0
- ata_coder/prompts/auto-mode.md +7 -0
- ata_coder/prompts/coding-rules.md +40 -0
- ata_coder/prompts/execution-guardrails.md +14 -0
- ata_coder/prompts/memory-system.md +24 -0
- ata_coder/prompts/output-style.md +23 -0
- ata_coder/prompts/safety.md +17 -0
- ata_coder/prompts/slash-commands.md +24 -0
- ata_coder/prompts/sub-agents.md +38 -0
- ata_coder/prompts/system-reminders.md +17 -0
- ata_coder/prompts/system.md +105 -0
- ata_coder/prompts/tool-policy.md +46 -0
- ata_coder/repl_theme.py +99 -0
- ata_coder/repl_tracker.py +89 -0
- ata_coder/repl_ui.py +1214 -0
- ata_coder/safety_guard.py +434 -0
- ata_coder/self_correct.py +346 -0
- ata_coder/server.py +882 -0
- ata_coder/server_session.py +159 -0
- ata_coder/server_shell.py +129 -0
- ata_coder/session.py +431 -0
- ata_coder/settings.py +439 -0
- ata_coder/setup_wizard.py +136 -0
- ata_coder/skill_extension.py +92 -0
- ata_coder/skills/architect/SKILL.md +42 -0
- ata_coder/skills/code-reviewer/SKILL.md +37 -0
- ata_coder/skills/codecraft/SKILL.md +452 -0
- ata_coder/skills/debugger/SKILL.md +45 -0
- ata_coder/skills/doc-writer/SKILL.md +36 -0
- ata_coder/skills/general-coder/SKILL.md +76 -0
- ata_coder/skills/math-calculator/README.md +40 -0
- ata_coder/skills/math-calculator/SKILL.md +59 -0
- ata_coder/skills/math-calculator/handler.py +103 -0
- ata_coder/skills/math-calculator/prompts/system.md +8 -0
- ata_coder/skills/math-calculator/requirements.txt +2 -0
- ata_coder/skills/math-calculator/resources/constants.json +8 -0
- ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
- ata_coder/skills/security-auditor/SKILL.md +40 -0
- ata_coder/skills/test-writer/SKILL.md +36 -0
- ata_coder/skills/weather-skill/README.md +45 -0
- ata_coder/skills/weather-skill/handler.py +76 -0
- ata_coder/skills/weather-skill/manifest.json +48 -0
- ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
- ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
- ata_coder/skills/weather-skill/requirements.txt +1 -0
- ata_coder/skills/weather-skill/resources/city_list.json +17 -0
- ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
- ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
- ata_coder/skills/weather-skill/weather_utils.py +50 -0
- ata_coder/skills.py +1014 -0
- ata_coder/sub_agent.py +273 -0
- ata_coder/sub_agent_manager.py +203 -0
- ata_coder/system_prompt_builder.py +146 -0
- ata_coder/task_planner.py +391 -0
- ata_coder/terminal.py +318 -0
- ata_coder/test_runner.py +219 -0
- ata_coder/thread_supervisor.py +195 -0
- ata_coder/tool_defs.py +335 -0
- ata_coder/tools/__init__.py +11 -0
- ata_coder/tools/definitions.py +335 -0
- ata_coder/tools/executor.py +1036 -0
- ata_coder/tools/result.py +26 -0
- ata_coder/tools/subagent.py +332 -0
- ata_coder/tools/web.py +361 -0
- ata_coder/tools.py +1576 -0
- ata_coder/types.py +92 -0
- ata_coder/utils.py +113 -0
- ata_coder/web/css/style.css +180 -0
- ata_coder/web/index.html +84 -0
- ata_coder/web/js/app.js +489 -0
- ata_coder/web/package-lock.json +25 -0
- ata_coder/web/package.json +10 -0
- ata_coder/web/tsconfig.json +13 -0
- ata_coder-2.4.2.dist-info/METADATA +799 -0
- ata_coder-2.4.2.dist-info/RECORD +118 -0
- ata_coder-2.4.2.dist-info/WHEEL +5 -0
- ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
- ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
- ata_coder-2.4.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Model routing — length shortcuts + AI-driven complexity classification + model selection.
|
|
3
|
+
|
|
4
|
+
Flow:
|
|
5
|
+
1. User sends a task
|
|
6
|
+
2. shortcut_classify() tries a cheap length-based heuristic
|
|
7
|
+
3. If ambiguous, the cheap model classifies: simple or complex?
|
|
8
|
+
4. Simple → keep cheap model | Complex → switch to powerful model
|
|
9
|
+
|
|
10
|
+
No hardcoded keywords — the cheap model itself judges complexity for
|
|
11
|
+
medium-length tasks.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .settings import get_settings, Settings
|
|
18
|
+
|
|
19
|
+
logger = logging.getLogger(__name__)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModelRouter:
|
|
23
|
+
"""Consolidated complexity classification + model name resolution.
|
|
24
|
+
|
|
25
|
+
Pulls configuration from the global Settings singleton so callers
|
|
26
|
+
don't need to thread settings through every call site.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, settings: Settings | None = None):
|
|
30
|
+
self._settings = settings or get_settings()
|
|
31
|
+
|
|
32
|
+
# ── Complexity classification ──────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def classify_shortcut(self, task: str) -> str | None:
|
|
35
|
+
"""
|
|
36
|
+
Quick length-based shortcut — skip AI classify for obvious cases.
|
|
37
|
+
Returns 'simple', 'complex', or None (None = need AI classify).
|
|
38
|
+
|
|
39
|
+
Moved here from Settings.shortcut_classify to keep classification
|
|
40
|
+
logic in one place.
|
|
41
|
+
"""
|
|
42
|
+
if not self._settings.get("complexity", "auto_detect", default=True):
|
|
43
|
+
return "normal"
|
|
44
|
+
|
|
45
|
+
task_len = len(task.strip())
|
|
46
|
+
simple_max = self._settings.get("complexity", "simple_max_chars", default=60)
|
|
47
|
+
complex_min = self._settings.get("complexity", "complex_min_chars", default=500)
|
|
48
|
+
|
|
49
|
+
if task_len <= simple_max:
|
|
50
|
+
return "simple"
|
|
51
|
+
if task_len >= complex_min:
|
|
52
|
+
return "complex"
|
|
53
|
+
return None # middle ground → let AI decide
|
|
54
|
+
|
|
55
|
+
# ── Model resolution ───────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
def resolve(self, complexity: str) -> str:
|
|
58
|
+
"""Map a complexity label to a model name."""
|
|
59
|
+
mapping = {
|
|
60
|
+
"simple": self._settings.model_haiku,
|
|
61
|
+
"complex": self._settings.model_opus,
|
|
62
|
+
"normal": self._settings.default_model,
|
|
63
|
+
"explicit": self._settings.default_model,
|
|
64
|
+
}
|
|
65
|
+
return mapping.get(complexity, self._settings.default_model)
|
|
66
|
+
|
|
67
|
+
@property
|
|
68
|
+
def subagent_model(self) -> str:
|
|
69
|
+
return self._settings.model_subagent
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def complex_model(self) -> str:
|
|
73
|
+
return self._settings.model_opus
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def info(self) -> dict[str, Any]:
|
|
77
|
+
return {
|
|
78
|
+
"default": self._settings.default_model,
|
|
79
|
+
"opus": self._settings.model_opus,
|
|
80
|
+
"sonnet": self._settings.model_sonnet,
|
|
81
|
+
"haiku": self._settings.model_haiku,
|
|
82
|
+
"subagent": self._settings.model_subagent,
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ── Module-level convenience functions (keep backward compat) ──────────────
|
|
87
|
+
|
|
88
|
+
def get_subagent_model(settings: Settings | None = None) -> str:
|
|
89
|
+
"""Get the model name for classification/subagent tasks (cheaper/faster)."""
|
|
90
|
+
return ModelRouter(settings).subagent_model
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def get_complex_model(settings: Settings | None = None) -> str:
|
|
94
|
+
"""Get the model name for complex tasks (powerful)."""
|
|
95
|
+
return ModelRouter(settings).complex_model
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def resolve_model(complexity: str, settings: Settings | None = None) -> str:
|
|
99
|
+
"""Map a complexity label to a model name. Delegates to ModelRouter."""
|
|
100
|
+
return ModelRouter(settings).resolve(complexity)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def get_model_info(settings: Settings | None = None) -> dict[str, Any]:
|
|
104
|
+
"""Get all model configuration info for display. Delegates to ModelRouter."""
|
|
105
|
+
return ModelRouter(settings).info
|
ata_coder/permissions.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Interactive permission system — Claude Code style.
|
|
3
|
+
|
|
4
|
+
Controls whether the agent can execute tools that modify state:
|
|
5
|
+
- Shell commands (run_shell)
|
|
6
|
+
- File writes (write_file)
|
|
7
|
+
- File edits (edit_file)
|
|
8
|
+
|
|
9
|
+
Permission modes (per tool type):
|
|
10
|
+
- ask — prompt the user each time (default)
|
|
11
|
+
- allow — always allow for this session
|
|
12
|
+
- deny — always deny for this session
|
|
13
|
+
- once — allow once, then revert to ask
|
|
14
|
+
|
|
15
|
+
Permissions can be configured:
|
|
16
|
+
- Globally via settings (permissions.json)
|
|
17
|
+
- Per session via interactive prompts
|
|
18
|
+
- Per project via .ata_coder/permissions.json
|
|
19
|
+
|
|
20
|
+
The permission prompt shows:
|
|
21
|
+
- The tool being called
|
|
22
|
+
- The arguments (truncated for readability)
|
|
23
|
+
- Options: [y]es, [n]o, [a]llow all, [d]eny all
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import time
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
from enum import Enum
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
from typing import Any, Callable
|
|
33
|
+
|
|
34
|
+
logger = logging.getLogger(__name__)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _permissions_path() -> Path:
|
|
38
|
+
"""Get the permissions file path from settings or fallback."""
|
|
39
|
+
try:
|
|
40
|
+
from .settings import get_settings
|
|
41
|
+
return get_settings().data_dir / "permissions.json"
|
|
42
|
+
except Exception:
|
|
43
|
+
return Path.home() / ".ata_coder" / "permissions.json"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
# ── Permission mode ──────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
class PermissionMode(Enum):
|
|
49
|
+
ASK = "ask"
|
|
50
|
+
ALLOW = "allow"
|
|
51
|
+
DENY = "deny"
|
|
52
|
+
|
|
53
|
+
def to_label(self) -> str:
|
|
54
|
+
"""Human-readable label for this permission mode."""
|
|
55
|
+
if self == PermissionMode.ALLOW:
|
|
56
|
+
return "✅ ALLOW"
|
|
57
|
+
elif self == PermissionMode.DENY:
|
|
58
|
+
return "🚫 DENY"
|
|
59
|
+
return "❓ ASK"
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
# ── Tool categories ──────────────────────────────────────────────────────────
|
|
63
|
+
|
|
64
|
+
# Tools grouped by risk level
|
|
65
|
+
READ_TOOLS = {"read_file", "grep", "glob", "list_dir"}
|
|
66
|
+
WRITE_TOOLS = {"write_file", "edit_file"}
|
|
67
|
+
SHELL_TOOLS = {"run_shell"}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def tool_category(tool_name: str) -> str:
|
|
71
|
+
"""Get the category of a tool."""
|
|
72
|
+
if tool_name in READ_TOOLS:
|
|
73
|
+
return "read"
|
|
74
|
+
elif tool_name in WRITE_TOOLS:
|
|
75
|
+
return "write"
|
|
76
|
+
elif tool_name in SHELL_TOOLS:
|
|
77
|
+
return "shell"
|
|
78
|
+
elif tool_name.startswith("mcp__"):
|
|
79
|
+
return "mcp"
|
|
80
|
+
return "other"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ── Permission rules ─────────────────────────────────────────────────────────
|
|
84
|
+
|
|
85
|
+
@dataclass
|
|
86
|
+
class PermissionRule:
|
|
87
|
+
"""A single permission rule."""
|
|
88
|
+
tool_name: str # exact tool name or "*" for wildcard
|
|
89
|
+
mode: PermissionMode
|
|
90
|
+
category: str = "" # tool category for display
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class PermissionStore:
|
|
94
|
+
"""
|
|
95
|
+
Manages permission rules and interactive prompting.
|
|
96
|
+
|
|
97
|
+
Rules are checked in order of specificity:
|
|
98
|
+
1. Exact tool name match
|
|
99
|
+
2. Category match (e.g., "shell")
|
|
100
|
+
3. Wildcard "*" match
|
|
101
|
+
4. Default (ask)
|
|
102
|
+
"""
|
|
103
|
+
|
|
104
|
+
def __init__(self, project_dir: str | Path | None = None):
|
|
105
|
+
self._rules: dict[str, PermissionMode] = {}
|
|
106
|
+
self._category_rules: dict[str, PermissionMode] = {}
|
|
107
|
+
self._once_allowed: set[str] = set() # tool calls allowed for one shot
|
|
108
|
+
self._prompt_fn: Callable | None = None # interactive prompt callback
|
|
109
|
+
self._recent_allows: dict[str, float] = {} # category → expiry timestamp
|
|
110
|
+
self._recent_allow_ttl: float = 60.0 # cache recent allows for 60s
|
|
111
|
+
|
|
112
|
+
# Load project-level permissions
|
|
113
|
+
self._project_dir = Path(project_dir) if project_dir else None
|
|
114
|
+
self._load_project_permissions() # always load from ~/.ata_coder/
|
|
115
|
+
|
|
116
|
+
# ── Configuration ─────────────────────────────────────────────────────
|
|
117
|
+
|
|
118
|
+
def set_prompt_callback(self, fn: Callable) -> None:
|
|
119
|
+
"""Set the function to call for interactive prompts.
|
|
120
|
+
fn(tool_name, arguments, category) -> bool (allowed?)
|
|
121
|
+
"""
|
|
122
|
+
self._prompt_fn = fn
|
|
123
|
+
|
|
124
|
+
def set_rule(self, tool_name: str, mode: PermissionMode) -> None:
|
|
125
|
+
"""Set a permission rule for an exact tool name."""
|
|
126
|
+
if mode == PermissionMode.ASK:
|
|
127
|
+
self._rules.pop(tool_name, None)
|
|
128
|
+
else:
|
|
129
|
+
self._rules[tool_name] = mode
|
|
130
|
+
|
|
131
|
+
def set_category_rule(self, category: str, mode: PermissionMode) -> None:
|
|
132
|
+
"""Set a permission rule for a tool category."""
|
|
133
|
+
if mode == PermissionMode.ASK:
|
|
134
|
+
self._category_rules.pop(category, None)
|
|
135
|
+
else:
|
|
136
|
+
self._category_rules[category] = mode
|
|
137
|
+
|
|
138
|
+
def get_category_mode(self, category: str) -> PermissionMode | None:
|
|
139
|
+
"""Return the permission mode for a category, or None if not configured."""
|
|
140
|
+
return self._category_rules.get(category)
|
|
141
|
+
|
|
142
|
+
def allow_once(self, tool_name: str) -> None:
|
|
143
|
+
"""Allow a specific tool call once."""
|
|
144
|
+
self._once_allowed.add(tool_name)
|
|
145
|
+
|
|
146
|
+
# ── Permission check ──────────────────────────────────────────────────
|
|
147
|
+
|
|
148
|
+
def check(self, tool_name: str, arguments: dict[str, Any] | None = None) -> bool:
|
|
149
|
+
"""
|
|
150
|
+
Check if a tool call is allowed.
|
|
151
|
+
Returns True if allowed, False if denied.
|
|
152
|
+
|
|
153
|
+
For ASK mode, invokes the interactive prompt callback.
|
|
154
|
+
"""
|
|
155
|
+
category = tool_category(tool_name)
|
|
156
|
+
|
|
157
|
+
# 1. One-shot allow
|
|
158
|
+
if tool_name in self._once_allowed:
|
|
159
|
+
self._once_allowed.discard(tool_name)
|
|
160
|
+
return True
|
|
161
|
+
|
|
162
|
+
# 2. Exact tool name rule
|
|
163
|
+
if tool_name in self._rules:
|
|
164
|
+
mode = self._rules[tool_name]
|
|
165
|
+
if mode == PermissionMode.ALLOW:
|
|
166
|
+
return True
|
|
167
|
+
elif mode == PermissionMode.DENY:
|
|
168
|
+
logger.info("Denied by rule: %s", tool_name)
|
|
169
|
+
return False
|
|
170
|
+
|
|
171
|
+
# 3. Category rule
|
|
172
|
+
if category in self._category_rules:
|
|
173
|
+
mode = self._category_rules[category]
|
|
174
|
+
if mode == PermissionMode.ALLOW:
|
|
175
|
+
return True
|
|
176
|
+
elif mode == PermissionMode.DENY:
|
|
177
|
+
logger.info("Denied by category rule: %s", category)
|
|
178
|
+
return False
|
|
179
|
+
|
|
180
|
+
# 4. Wildcard rule
|
|
181
|
+
if "*" in self._rules:
|
|
182
|
+
mode = self._rules["*"]
|
|
183
|
+
if mode == PermissionMode.ALLOW:
|
|
184
|
+
return True
|
|
185
|
+
elif mode == PermissionMode.DENY:
|
|
186
|
+
logger.info("Denied by wildcard rule")
|
|
187
|
+
return False
|
|
188
|
+
|
|
189
|
+
# 5. Recent-allow cache (prevents permission storm)
|
|
190
|
+
if category in self._recent_allows:
|
|
191
|
+
if time.time() < self._recent_allows[category]:
|
|
192
|
+
return True
|
|
193
|
+
del self._recent_allows[category] # expired
|
|
194
|
+
|
|
195
|
+
# 6. Read tools always allowed (safe by default)
|
|
196
|
+
if category == "read":
|
|
197
|
+
return True
|
|
198
|
+
|
|
199
|
+
# 7. Interactive prompt for write/shell/mcp
|
|
200
|
+
if self._prompt_fn:
|
|
201
|
+
allowed = self._prompt_fn(tool_name, arguments or {}, category)
|
|
202
|
+
if allowed:
|
|
203
|
+
self._recent_allows[category] = time.time() + self._recent_allow_ttl
|
|
204
|
+
return allowed
|
|
205
|
+
|
|
206
|
+
# No prompt callback — deny by default for safety
|
|
207
|
+
logger.warning("No prompt callback, denying: %s", tool_name)
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
# ── Persistence ──────────────────────────────────────────────────────
|
|
211
|
+
|
|
212
|
+
def _load_project_permissions(self) -> None:
|
|
213
|
+
"""Load permissions from ~/.ata_coder/permissions.json."""
|
|
214
|
+
perms_file = _permissions_path()
|
|
215
|
+
if not perms_file.exists():
|
|
216
|
+
return
|
|
217
|
+
try:
|
|
218
|
+
with open(perms_file, "r", encoding="utf-8") as f:
|
|
219
|
+
data = json.load(f)
|
|
220
|
+
for tool_name, mode_str in data.get("rules", {}).items():
|
|
221
|
+
try:
|
|
222
|
+
self._rules[tool_name] = PermissionMode(mode_str)
|
|
223
|
+
except ValueError:
|
|
224
|
+
pass
|
|
225
|
+
for category, mode_str in data.get("categories", {}).items():
|
|
226
|
+
try:
|
|
227
|
+
self._category_rules[category] = PermissionMode(mode_str)
|
|
228
|
+
except ValueError:
|
|
229
|
+
pass
|
|
230
|
+
logger.debug("Loaded %d permission rules from project", len(data.get("rules", {})))
|
|
231
|
+
except Exception as e:
|
|
232
|
+
logger.warning("Failed to load project permissions: %s", e)
|
|
233
|
+
|
|
234
|
+
def save_project_permissions(self) -> None:
|
|
235
|
+
"""Save permissions to ~/.ata_coder/permissions.json."""
|
|
236
|
+
perms_file = _permissions_path()
|
|
237
|
+
perms_file.parent.mkdir(parents=True, exist_ok=True)
|
|
238
|
+
try:
|
|
239
|
+
data = {
|
|
240
|
+
"rules": {k: v.value for k, v in self._rules.items()},
|
|
241
|
+
"categories": {k: v.value for k, v in self._category_rules.items()},
|
|
242
|
+
}
|
|
243
|
+
with open(perms_file, "w", encoding="utf-8") as f:
|
|
244
|
+
json.dump(data, f, indent=2)
|
|
245
|
+
except Exception as e:
|
|
246
|
+
logger.warning("Failed to save project permissions: %s", e)
|
|
247
|
+
|
|
248
|
+
# ── Status ──────────────────────────────────────────────────────────
|
|
249
|
+
|
|
250
|
+
def describe(self) -> str:
|
|
251
|
+
"""Human-readable description of current permission state."""
|
|
252
|
+
lines = ["Permission Rules:"]
|
|
253
|
+
lines.append(" Reads: always allowed")
|
|
254
|
+
for category in ["shell", "write", "mcp"]:
|
|
255
|
+
if category in self._category_rules:
|
|
256
|
+
lines.append(f" {category}: {self._category_rules[category].value}")
|
|
257
|
+
else:
|
|
258
|
+
lines.append(f" {category}: ask")
|
|
259
|
+
for tool_name, mode in sorted(self._rules.items()):
|
|
260
|
+
if tool_name != "*":
|
|
261
|
+
lines.append(f" {tool_name}: {mode.value}")
|
|
262
|
+
return "\n".join(lines)
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
# ── Global ───────────────────────────────────────────────────────────────────
|
|
266
|
+
|
|
267
|
+
_permission_store: PermissionStore | None = None
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def get_permissions(project_dir: str | None = None) -> PermissionStore:
|
|
271
|
+
global _permission_store
|
|
272
|
+
if _permission_store is None:
|
|
273
|
+
_permission_store = PermissionStore(project_dir)
|
|
274
|
+
return _permission_store
|