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.
Files changed (118) hide show
  1. ata_coder/__init__.py +1 -0
  2. ata_coder/agent.py +874 -0
  3. ata_coder/agent_compact.py +190 -0
  4. ata_coder/agent_controller.py +218 -0
  5. ata_coder/agent_extension.py +69 -0
  6. ata_coder/agent_routing.py +105 -0
  7. ata_coder/agent_subsystems.py +72 -0
  8. ata_coder/agent_tools.py +318 -0
  9. ata_coder/agent_undo.py +63 -0
  10. ata_coder/anthropic_client.py +465 -0
  11. ata_coder/change_tracker.py +368 -0
  12. ata_coder/clawd_integration.py +574 -0
  13. ata_coder/commands/__init__.py +128 -0
  14. ata_coder/commands/_core.py +184 -0
  15. ata_coder/commands/_safety.py +95 -0
  16. ata_coder/commands/_settings.py +241 -0
  17. ata_coder/commands/_workflow.py +451 -0
  18. ata_coder/commands.py +974 -0
  19. ata_coder/config.py +257 -0
  20. ata_coder/core/__init__.py +35 -0
  21. ata_coder/core/events.py +73 -0
  22. ata_coder/core/queue.py +85 -0
  23. ata_coder/core/state.py +17 -0
  24. ata_coder/event_queue.py +5 -0
  25. ata_coder/extension.py +654 -0
  26. ata_coder/extensions/__init__.py +1 -0
  27. ata_coder/extensions/hello_skill.py +47 -0
  28. ata_coder/fool_proof.py +295 -0
  29. ata_coder/git_workflow.py +371 -0
  30. ata_coder/gui.py +511 -0
  31. ata_coder/llm_client.py +543 -0
  32. ata_coder/main.py +814 -0
  33. ata_coder/mcp_client.py +1095 -0
  34. ata_coder/memory.py +539 -0
  35. ata_coder/model_registry.py +134 -0
  36. ata_coder/model_router.py +105 -0
  37. ata_coder/permissions.py +274 -0
  38. ata_coder/privilege.py +464 -0
  39. ata_coder/project.py +273 -0
  40. ata_coder/prompt_template.py +423 -0
  41. ata_coder/prompts/auto-mode.md +7 -0
  42. ata_coder/prompts/coding-rules.md +40 -0
  43. ata_coder/prompts/execution-guardrails.md +14 -0
  44. ata_coder/prompts/memory-system.md +24 -0
  45. ata_coder/prompts/output-style.md +23 -0
  46. ata_coder/prompts/safety.md +17 -0
  47. ata_coder/prompts/slash-commands.md +24 -0
  48. ata_coder/prompts/sub-agents.md +38 -0
  49. ata_coder/prompts/system-reminders.md +17 -0
  50. ata_coder/prompts/system.md +105 -0
  51. ata_coder/prompts/tool-policy.md +46 -0
  52. ata_coder/repl_theme.py +99 -0
  53. ata_coder/repl_tracker.py +89 -0
  54. ata_coder/repl_ui.py +1214 -0
  55. ata_coder/safety_guard.py +434 -0
  56. ata_coder/self_correct.py +346 -0
  57. ata_coder/server.py +882 -0
  58. ata_coder/server_session.py +159 -0
  59. ata_coder/server_shell.py +129 -0
  60. ata_coder/session.py +431 -0
  61. ata_coder/settings.py +439 -0
  62. ata_coder/setup_wizard.py +136 -0
  63. ata_coder/skill_extension.py +92 -0
  64. ata_coder/skills/architect/SKILL.md +42 -0
  65. ata_coder/skills/code-reviewer/SKILL.md +37 -0
  66. ata_coder/skills/codecraft/SKILL.md +452 -0
  67. ata_coder/skills/debugger/SKILL.md +45 -0
  68. ata_coder/skills/doc-writer/SKILL.md +36 -0
  69. ata_coder/skills/general-coder/SKILL.md +76 -0
  70. ata_coder/skills/math-calculator/README.md +40 -0
  71. ata_coder/skills/math-calculator/SKILL.md +59 -0
  72. ata_coder/skills/math-calculator/handler.py +103 -0
  73. ata_coder/skills/math-calculator/prompts/system.md +8 -0
  74. ata_coder/skills/math-calculator/requirements.txt +2 -0
  75. ata_coder/skills/math-calculator/resources/constants.json +8 -0
  76. ata_coder/skills/math-calculator/tests/test_handler.py +53 -0
  77. ata_coder/skills/security-auditor/SKILL.md +40 -0
  78. ata_coder/skills/test-writer/SKILL.md +36 -0
  79. ata_coder/skills/weather-skill/README.md +45 -0
  80. ata_coder/skills/weather-skill/handler.py +76 -0
  81. ata_coder/skills/weather-skill/manifest.json +48 -0
  82. ata_coder/skills/weather-skill/prompts/system_prompt.txt +9 -0
  83. ata_coder/skills/weather-skill/prompts/user_prompt_template.txt +3 -0
  84. ata_coder/skills/weather-skill/requirements.txt +1 -0
  85. ata_coder/skills/weather-skill/resources/city_list.json +17 -0
  86. ata_coder/skills/weather-skill/resources/error_messages.json +7 -0
  87. ata_coder/skills/weather-skill/tests/test_handler.py +28 -0
  88. ata_coder/skills/weather-skill/weather_utils.py +50 -0
  89. ata_coder/skills.py +1014 -0
  90. ata_coder/sub_agent.py +273 -0
  91. ata_coder/sub_agent_manager.py +203 -0
  92. ata_coder/system_prompt_builder.py +146 -0
  93. ata_coder/task_planner.py +391 -0
  94. ata_coder/terminal.py +318 -0
  95. ata_coder/test_runner.py +219 -0
  96. ata_coder/thread_supervisor.py +195 -0
  97. ata_coder/tool_defs.py +335 -0
  98. ata_coder/tools/__init__.py +11 -0
  99. ata_coder/tools/definitions.py +335 -0
  100. ata_coder/tools/executor.py +1036 -0
  101. ata_coder/tools/result.py +26 -0
  102. ata_coder/tools/subagent.py +332 -0
  103. ata_coder/tools/web.py +361 -0
  104. ata_coder/tools.py +1576 -0
  105. ata_coder/types.py +92 -0
  106. ata_coder/utils.py +113 -0
  107. ata_coder/web/css/style.css +180 -0
  108. ata_coder/web/index.html +84 -0
  109. ata_coder/web/js/app.js +489 -0
  110. ata_coder/web/package-lock.json +25 -0
  111. ata_coder/web/package.json +10 -0
  112. ata_coder/web/tsconfig.json +13 -0
  113. ata_coder-2.4.2.dist-info/METADATA +799 -0
  114. ata_coder-2.4.2.dist-info/RECORD +118 -0
  115. ata_coder-2.4.2.dist-info/WHEEL +5 -0
  116. ata_coder-2.4.2.dist-info/entry_points.txt +2 -0
  117. ata_coder-2.4.2.dist-info/licenses/LICENSE +21 -0
  118. ata_coder-2.4.2.dist-info/top_level.txt +1 -0
ata_coder/main.py ADDED
@@ -0,0 +1,814 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ ATA Coder — Claude Code-compatible CLI.
4
+
5
+ A full-featured AI coding assistant with OpenAI-compatible APIs.
6
+
7
+ Usage:
8
+ ata # Interactive mode
9
+ ata run "Add type hints" # Single task
10
+ ata server # Start API server
11
+ ata --skill debugger # Interactive with forced skill
12
+ ata --resume <session-id> # Resume session
13
+ """
14
+ import sys
15
+ if sys.version_info < (3, 10):
16
+ sys.exit("Python 3.10 or higher is required for ATA Coder.")
17
+
18
+ # ── Kill GBK _readerthread errors on Windows ─────────────────────────────
19
+ # CPython's subprocess module spawns a daemon _readerthread that uses the
20
+ # system locale (GBK on Chinese Windows) when text=True. Any non-ASCII
21
+ # output crashes the thread on interpreter shutdown with UnicodeDecodeError.
22
+ # We monkey-patch subprocess.Popen to default encoding='utf-8',errors='replace'.
23
+ if sys.platform == 'win32':
24
+ # Windows subprocess defaults to the system ANSI code page (e.g. GBK on
25
+ # Chinese Windows) for text-mode pipes, which corrupts UTF-8 output from
26
+ # modern CLI tools. This monkey-patch forces utf-8 + errors='replace' on
27
+ # every subprocess.Popen call in the process. It is deliberately placed
28
+ # BEFORE any other imports so that ALL downstream Popen usage is covered.
29
+ #
30
+ # Risk: global side-effect that affects third-party libraries using Popen.
31
+ # Mitigation: binary-mode calls (no encoding/text/universal_newlines) are
32
+ # left untouched, so the patch is a no-op for the common binary-pipe case.
33
+ import subprocess as _sp
34
+ _orig_init = _sp.Popen.__init__
35
+ def _patched_init(self, *a, **kw):
36
+ if 'encoding' not in kw and 'text' not in kw and 'universal_newlines' not in kw:
37
+ pass # binary mode — no encoding needed
38
+ else:
39
+ kw.setdefault('encoding', 'utf-8')
40
+ kw.setdefault('errors', 'replace')
41
+ _orig_init(self, *a, **kw)
42
+ _sp.Popen.__init__ = _patched_init
43
+
44
+ __version__ = "2.4.2"
45
+
46
+ import asyncio
47
+ import logging
48
+ import os
49
+ import platform
50
+ import signal
51
+ import sys
52
+ from pathlib import Path
53
+
54
+ import click
55
+
56
+ # Allow running directly (python main.py) without pip install -e .
57
+ # When the package IS installed, this is a harmless no-op.
58
+ _PKG_DIR = str(Path(__file__).parent.resolve())
59
+ if _PKG_DIR not in sys.path:
60
+ sys.path.insert(0, _PKG_DIR)
61
+
62
+ from .config import AppConfig, get_config
63
+ from .tools import ToolExecutor, TOOL_DEFINITIONS
64
+ from .skills import get_skill_manager
65
+ from .memory import get_memory_store
66
+ from .setup_wizard import ensure_first_run as _ensure_first_run
67
+ from .session import SessionManager
68
+ from .project import ProjectDetector
69
+ from .permissions import PermissionStore, PermissionMode
70
+ from .repl_ui import ClaudeCodeUI, HAS_RICH
71
+ from .agent import CoderAgent
72
+ from .agent_subsystems import AgentSubsystems
73
+ from .clawd_integration import create_clawd_permission_handler, get_clawd
74
+
75
+ logger = logging.getLogger(__name__)
76
+
77
+ _cleanup_handlers: list = []
78
+
79
+
80
+ def register_cleanup(handler) -> None:
81
+ _cleanup_handlers.append(handler)
82
+
83
+
84
+ def _signal_handler(sig, frame):
85
+ print("\n[Interrupted]")
86
+ # Clawd: notify desktop pet before exit
87
+ try:
88
+ get_clawd().shutdown()
89
+ except Exception:
90
+ pass
91
+ for handler in _cleanup_handlers:
92
+ try:
93
+ handler()
94
+ except Exception:
95
+ pass
96
+ sys.exit(1)
97
+
98
+
99
+ signal.signal(signal.SIGINT, _signal_handler)
100
+
101
+
102
+ # ── Subsystem init ──────────────────────────────────────────────────────
103
+
104
+ class SubsystemInitError(Exception):
105
+ """Critical subsystem failed to initialize — agent cannot start."""
106
+
107
+
108
+ def _init_subsystems(config, **kwargs) -> dict:
109
+ """Initialize all subsystems.
110
+
111
+ Critical subsystems (skills, memory, permissions) raise on failure.
112
+ Non-critical subsystems log a warning and continue with None.
113
+ """
114
+ result = {
115
+ "skills": None, "memory": None, "mcp": None,
116
+ "templates": None, "sessions": None, "project": None,
117
+ "permissions": None,
118
+ }
119
+ workspace = config.agent.workspace_dir
120
+ errors: list[str] = []
121
+
122
+ # ── Critical: agent cannot function without these ──────────────────
123
+ for name, factory in [
124
+ ("skills", lambda: get_skill_manager(kwargs.get("skills_dir"))),
125
+ ("memory", lambda: get_memory_store(kwargs.get("memory_dir"))),
126
+ ("permissions", lambda: PermissionStore()),
127
+ ]:
128
+ try:
129
+ result[name] = factory()
130
+ except Exception as e:
131
+ logger.exception("%s init failed", name)
132
+ errors.append(f" {name}: {e}")
133
+
134
+ # ── Non-critical: nice-to-have, degrade gracefully ─────────────────
135
+ for name, factory in [
136
+ ("sessions", SessionManager),
137
+ ("templates", lambda: _try_init_templates(kwargs.get("prompts_dir"))),
138
+ ("project", lambda: ProjectDetector(workspace).detect()),
139
+ ]:
140
+ try:
141
+ result[name] = factory()
142
+ except Exception as e:
143
+ logger.warning("%s unavailable: %s", name, e)
144
+ result[name] = None
145
+
146
+ # MCP is special: only init if config provided
147
+ result["mcp"] = _try_init_mcp(kwargs.get("mcp_config"))
148
+
149
+ if errors:
150
+ raise SubsystemInitError(
151
+ "Critical subsystems failed to initialize:\n"
152
+ + "\n".join(errors)
153
+ + "\n\nCheck your installation or environment variables."
154
+ )
155
+ return result
156
+
157
+
158
+ def _try_init_templates(prompts_dir: str | None):
159
+ from .prompt_template import TemplateManager
160
+ return TemplateManager(prompts_dir)
161
+
162
+
163
+ def _try_init_mcp(mcp_config: str | None):
164
+ if not mcp_config:
165
+ return None
166
+ from .mcp_client import MCPClient, load_mcp_config
167
+ return MCPClient(load_mcp_config(mcp_config))
168
+
169
+
170
+ # ── Config override ─────────────────────────────────────────────────────
171
+
172
+ def _apply_config_overrides(config: AppConfig, kwargs: dict) -> str:
173
+ explicit_model = ""
174
+ if kwargs.get("model"):
175
+ config.llm.model = kwargs["model"]
176
+ explicit_model = kwargs["model"]
177
+ if kwargs.get("api_key"):
178
+ config.llm.api_key = kwargs["api_key"]
179
+ if kwargs.get("base_url"):
180
+ config.llm.base_url = kwargs["base_url"]
181
+ if kwargs.get("workspace"):
182
+ config.agent.workspace_dir = os.path.abspath(
183
+ os.path.expanduser(kwargs["workspace"]))
184
+ if kwargs.get("max_tool_calls"):
185
+ config.agent.max_tool_calls = kwargs["max_tool_calls"]
186
+ if kwargs.get("think"):
187
+ config.llm.thinking_strength = kwargs["think"]
188
+ if kwargs.get("anthropic"):
189
+ config.llm.use_anthropic = True
190
+ return explicit_model
191
+
192
+
193
+ # ── Startup banner / First-run setup → setup_wizard.py
194
+
195
+
196
+
197
+
198
+ # ── Interactive mode ────────────────────────────────────────────────────
199
+
200
+ async def run_interactive_async(config: AppConfig, **kwargs):
201
+ """Async REPL loop — runs on the asyncio event loop."""
202
+ ui = ClaudeCodeUI()
203
+
204
+ from .commands import get_command_list
205
+ ui.setup_command_completion(get_command_list())
206
+
207
+ explicit_model = kwargs.get("model", "") or ""
208
+ subsystems = _init_subsystems(config, **kwargs)
209
+
210
+ skill_mgr = subsystems["skills"]
211
+ memory_store = subsystems["memory"]
212
+ mcp_client = subsystems["mcp"]
213
+ template_mgr = subsystems["templates"]
214
+ session_mgr = subsystems["sessions"]
215
+ project_info = subsystems["project"]
216
+ permission_store = subsystems["permissions"]
217
+
218
+ def on_permission_change(action: str, target: str):
219
+ if action == "allow_category":
220
+ permission_store.set_category_rule(target, PermissionMode.ALLOW)
221
+ if HAS_RICH:
222
+ ui.console.print(f"[dim]Allowed all {target} commands for this session.[/dim]")
223
+ elif action == "deny_category":
224
+ permission_store.set_category_rule(target, PermissionMode.DENY)
225
+ if HAS_RICH:
226
+ ui.console.print(f"[dim]Denied all {target} commands for this session.[/dim]")
227
+
228
+ ui.set_permission_callback(on_permission_change)
229
+
230
+ # Wrap the built-in permission prompt with Clawd bubble support.
231
+ # When Clawd is running, permission decisions go through its
232
+ # interactive bubble UI (Y/N/A/D). Falls back to the built-in
233
+ # terminal prompt when Clawd is unreachable.
234
+ _clawd_perm = create_clawd_permission_handler()
235
+ _builtin_prompt = ui.permission_prompt
236
+
237
+ def _combined_permission(tool_name: str, arguments: dict, category: str) -> bool:
238
+ if _clawd_perm is not None:
239
+ result = _clawd_perm(tool_name, arguments, category)
240
+ if result is not None:
241
+ return result
242
+ return _builtin_prompt(tool_name, arguments, category)
243
+
244
+ permission_store.set_prompt_callback(_combined_permission)
245
+
246
+ if kwargs.get("allow_all"):
247
+ permission_store.set_category_rule("shell", PermissionMode.ALLOW)
248
+ permission_store.set_category_rule("write", PermissionMode.ALLOW)
249
+ if kwargs.get("deny_shell"):
250
+ permission_store.set_category_rule("shell", PermissionMode.DENY)
251
+
252
+ active_skill = kwargs.get("skill") or "general-coder"
253
+ if skill_mgr:
254
+ skill_mgr.activate(active_skill)
255
+ auto_skill_state = {"value": not kwargs.get("no_skill_auto", False)}
256
+
257
+ resume_id = kwargs.get("resume")
258
+ resume_messages = None
259
+ if resume_id and session_mgr:
260
+ resume_messages = session_mgr.load(resume_id)
261
+ if resume_messages:
262
+ if HAS_RICH:
263
+ ui.console.print(f"[green]Resumed session: {resume_id}[/green]")
264
+ else:
265
+ print(f"Resumed: {resume_id}")
266
+
267
+ mcp_names = mcp_client.connected_servers if mcp_client else []
268
+ ui.show_welcome(
269
+ config.llm.model, config.agent.workspace_dir,
270
+ active_skill, project_info, mcp_names,
271
+ )
272
+
273
+ tool_exec = ToolExecutor(config.agent)
274
+ tool_exec.on_edit(ui.track_edit)
275
+ tool_exec.setup_file_cache(Path(config.agent.workspace_dir) / ".ata_coder" / "files")
276
+
277
+ # ── Create AgentController (runs agent on background thread) ──────────
278
+ from .agent_controller import AgentController
279
+
280
+ subsys = AgentSubsystems(
281
+ skills=skill_mgr, memory=memory_store, mcp=mcp_client,
282
+ templates=template_mgr, permissions=permission_store,
283
+ project_info=project_info, sessions=session_mgr,
284
+ )
285
+ controller = AgentController(
286
+ config=config, subsystems=subsys, tool_executor=tool_exec,
287
+ )
288
+ await controller.start()
289
+
290
+ # Clawd: SessionStart — one session per REPL, not per task
291
+ _clawd = get_clawd()
292
+ workspace_str = str(controller.agent.tools.workspace) if controller.agent else os.getcwd()
293
+ _clawd.start(session_id=controller.agent.session_id if controller.agent else "", cwd=workspace_str)
294
+
295
+ # Wire usage tracking (events go through EventQueue, not callback)
296
+ if controller.agent:
297
+ controller.agent.llm.on_usage(ui.track_usage)
298
+
299
+ if resume_messages and controller.agent:
300
+ controller.agent._state.messages = resume_messages
301
+ # Reuse the resumed session ID so auto-save updates the same session
302
+ controller.agent._current_session_id = resume_id
303
+
304
+ running = True
305
+ while running:
306
+ try:
307
+ session_info = ""
308
+ agent_ref = controller.agent
309
+ if agent_ref and agent_ref.session_id:
310
+ tokens = agent_ref.get_token_estimate()
311
+ parts = [f"tokens=~{tokens:,}"]
312
+ if agent_ref.git:
313
+ gs = agent_ref.git.get_status()
314
+ if gs.is_dirty():
315
+ parts.append(f"git:{gs.summary()}")
316
+ session_info = " ".join(parts)
317
+
318
+ dangerous = (
319
+ agent_ref.privilege_mgr.is_dangerous
320
+ if agent_ref and agent_ref.privilege_mgr else False
321
+ )
322
+ user_input = await ui.get_input(session_info, dangerous=dangerous)
323
+ except (KeyboardInterrupt, EOFError):
324
+ sid = getattr(agent_ref, "_current_session_id", "") if agent_ref else ""
325
+ hash_suffix = sid.rsplit("-", 1)[-1] if "-" in sid else sid
326
+ if hash_suffix and len(hash_suffix) >= 6:
327
+ print(f"\nResume this session with:\n ata --resume {hash_suffix}")
328
+ else:
329
+ print("\nGoodbye!")
330
+ break
331
+
332
+ if not user_input:
333
+ continue
334
+
335
+ cmd, arg = _parse_command(user_input)
336
+
337
+ if cmd is None:
338
+ print()
339
+ # ── Task execution via AgentController ──────────────────────
340
+ skill_to_use = None
341
+ if auto_skill_state["value"] and skill_mgr:
342
+ detected = skill_mgr.detect_skill(user_input)
343
+ if detected and detected.name != active_skill:
344
+ skill_to_use = detected.name
345
+ skill_mgr.activate(skill_to_use)
346
+ if HAS_RICH:
347
+ ui.console.print(f" [yellow][auto-skill] {skill_to_use}[/yellow]")
348
+ else:
349
+ print(f" [auto-skill] {skill_to_use}")
350
+
351
+ # Clawd: notify the pet that the user submitted a new task
352
+ get_clawd().user_prompt(prompt=user_input)
353
+
354
+ # Submit to background thread
355
+ await controller.submit(
356
+ user_input, skill_name=skill_to_use,
357
+ explicit_model=explicit_model, stream=True,
358
+ )
359
+
360
+ # Drain events while agent is busy (keeps UI responsive)
361
+ try:
362
+ while controller.is_busy():
363
+ for event in await controller.event_queue.drain():
364
+ ui.on_event(event)
365
+ # Check for interrupt
366
+ if controller._cancel.is_set():
367
+ break
368
+ await asyncio.sleep(0.05)
369
+ # Drain remaining events after completion
370
+ for event in await controller.event_queue.drain():
371
+ ui.on_event(event)
372
+ except KeyboardInterrupt:
373
+ await controller.cancel()
374
+ print("\n[Interrupted]")
375
+ ui.reset_stream()
376
+ # Drain remaining events
377
+ for event in await controller.event_queue.drain():
378
+ ui.on_event(event)
379
+ continue
380
+ except Exception as e:
381
+ if HAS_RICH:
382
+ from rich.markup import escape as rich_escape
383
+ ui.console.print(f"\n[red bold]Error:[/red bold] [red]{rich_escape(str(e))}[/red]")
384
+ else:
385
+ print(f"\nError: {e}")
386
+ logger.exception("Agent run failed")
387
+ finally:
388
+ # Save session
389
+ if session_mgr and controller.agent and controller.agent.session_id:
390
+ try:
391
+ controller.agent.save_session()
392
+ except Exception:
393
+ pass
394
+ continue
395
+
396
+ running = await _dispatch_command(cmd, arg, controller.agent, config, ui,
397
+ skill_mgr, memory_store, session_mgr,
398
+ mcp_client, template_mgr, permission_store,
399
+ auto_skill_state)
400
+
401
+ if session_mgr and controller.agent and controller.agent._state.messages:
402
+ try:
403
+ controller.agent.save_session()
404
+ except Exception:
405
+ pass
406
+ tool_exec.clear_file_cache()
407
+
408
+ # Clawd: SessionEnd — await final event delivery before loop stops
409
+ await get_clawd().shutdown_async()
410
+
411
+ await controller.shutdown()
412
+
413
+
414
+ # ── Single task ─────────────────────────────────────────────────────────
415
+
416
+ async def run_single_task_async(task: str, config: AppConfig, **kwargs):
417
+ ui = ClaudeCodeUI()
418
+ subsystems = _init_subsystems(config, **kwargs)
419
+ permission_store = subsystems["permissions"]
420
+
421
+ # Wrap the built-in permission prompt with Clawd bubble support.
422
+ _clawd_perm_st = create_clawd_permission_handler()
423
+ _builtin_prompt_st = ui.permission_prompt
424
+
425
+ def _combined_permission_st(tool_name: str, arguments: dict, category: str) -> bool:
426
+ if _clawd_perm_st is not None:
427
+ result = _clawd_perm_st(tool_name, arguments, category)
428
+ if result is not None:
429
+ return result
430
+ return _builtin_prompt_st(tool_name, arguments, category)
431
+
432
+ permission_store.set_prompt_callback(_combined_permission_st)
433
+
434
+ if kwargs.get("allow_all"):
435
+ permission_store.set_category_rule("shell", PermissionMode.ALLOW)
436
+ permission_store.set_category_rule("write", PermissionMode.ALLOW)
437
+
438
+ explicit_model = kwargs.get("model", "") or ""
439
+ tool_exec = ToolExecutor(config.agent)
440
+ tool_exec.on_edit(ui.track_edit)
441
+ tool_exec.setup_file_cache(Path(config.agent.workspace_dir) / ".ata_coder" / "files")
442
+
443
+ agent = CoderAgent(
444
+ config=config, tool_executor=tool_exec,
445
+ subsystems=AgentSubsystems(
446
+ skills=subsystems["skills"], memory=subsystems["memory"],
447
+ mcp=subsystems["mcp"], templates=subsystems["templates"],
448
+ permissions=permission_store, project_info=subsystems["project"],
449
+ sessions=subsystems["sessions"],
450
+ ),
451
+ )
452
+ agent.on_event(ui.on_event)
453
+ agent.llm.on_usage(ui.track_usage)
454
+
455
+ skill_name = kwargs.get("skill")
456
+ no_stream = kwargs.get("no_stream", False)
457
+
458
+ # Clawd: for single-task mode, the session IS this task
459
+ _clawd_st = get_clawd()
460
+ _clawd_st.start(session_id="", cwd=str(agent.tools.workspace), title=task)
461
+ _clawd_st.user_prompt(prompt=task)
462
+
463
+ try:
464
+ await agent.run(task, stream=not no_stream, skill_name=skill_name,
465
+ explicit_model=explicit_model)
466
+ print()
467
+ if subsystems["sessions"]:
468
+ mid = agent.save_session()
469
+ print(f"Session: {mid}")
470
+ return 0
471
+ except KeyboardInterrupt:
472
+ print("\nInterrupted.")
473
+ return 1
474
+ except Exception as e:
475
+ print(f"\nError: {e}")
476
+ logger.exception("Agent run failed")
477
+ return 1
478
+ finally:
479
+ tool_exec.clear_file_cache()
480
+ # Clawd: goodbye animation first
481
+ _clawd_st.shutdown()
482
+ await agent.shutdown()
483
+
484
+
485
+ # ── Command dispatch ────────────────────────────────────────────────────
486
+
487
+ _registry = None
488
+
489
+
490
+ async def _dispatch_command(cmd, arg, agent, config, ui, skill_mgr, memory_store,
491
+ session_mgr, mcp_client, template_mgr,
492
+ permission_store, auto_skill_state) -> bool:
493
+ global _registry
494
+ if _registry is None:
495
+ from .commands import build_registry
496
+ _registry = build_registry()
497
+
498
+ from .commands import CommandContext
499
+ ctx = CommandContext(
500
+ agent=agent, config=config, ui=ui,
501
+ skill_mgr=skill_mgr, memory_store=memory_store,
502
+ session_mgr=session_mgr, mcp_client=mcp_client,
503
+ template_mgr=template_mgr, permission_store=permission_store,
504
+ auto_skill_state=auto_skill_state,
505
+ )
506
+
507
+ result = await _registry.dispatch(cmd, arg, ctx)
508
+ if result is None:
509
+ from .commands import get_command_list
510
+ all_cmds = get_command_list()
511
+ matches = [(n, d) for n, d in all_cmds if n.startswith(cmd)]
512
+ if matches:
513
+ click.echo(f"\n Unknown: {cmd} — Did you mean?")
514
+ for name, desc in matches[:10]:
515
+ click.echo(f" {name:<18} {desc}")
516
+ else:
517
+ click.echo(f"\n Unknown: {cmd} — Available commands:")
518
+ shown = set()
519
+ for name, desc in sorted(all_cmds):
520
+ if name not in shown:
521
+ shown.add(name)
522
+ click.echo(f" {name:<18} {desc}")
523
+ click.echo()
524
+ return True
525
+ return result
526
+
527
+
528
+ def _parse_command(user_input: str) -> tuple:
529
+ if user_input.startswith("/"):
530
+ parts = user_input.split(maxsplit=1)
531
+ return parts[0].lower(), parts[1] if len(parts) > 1 else ""
532
+ return None, user_input
533
+
534
+
535
+ # ═════════════════════════════════════════════════════════════════════════
536
+ # Click CLI
537
+ # ═════════════════════════════════════════════════════════════════════════
538
+
539
+ def _setup(config, kwargs):
540
+ """Shared bootstrap: first-run check, UTF-8, logging, config, validation."""
541
+ import io
542
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
543
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
544
+
545
+ log_level = logging.DEBUG if kwargs.get("verbose") else logging.WARNING
546
+ logging.basicConfig(
547
+ level=log_level,
548
+ format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
549
+ )
550
+
551
+ from .settings import init_settings
552
+ init_settings()
553
+
554
+ _apply_config_overrides(config, kwargs)
555
+
556
+ errors = config.llm.validate()
557
+ if errors:
558
+ click.echo("\n[!] Configuration:", err=True)
559
+ for e in errors:
560
+ click.echo(f" - {e}", err=True)
561
+ click.echo(" Run 'ata' in interactive mode to set up your API key.\n", err=True)
562
+
563
+
564
+ # Shared click options
565
+ _GLOBAL_OPTIONS = [
566
+ click.option("--model", "-m", default=None, help="Model name"),
567
+ click.option("--api-key", "-k", default=None, help="API key"),
568
+ click.option("--base-url", "-b", default=None, help="API base URL"),
569
+ click.option("--workspace", "-w", default=None, help="Workspace directory"),
570
+ click.option("--verbose", is_flag=True, help="Verbose logging"),
571
+ click.option("--max-tool-calls", type=int, help="Max tool calls per task"),
572
+ click.option("--skill", "-s", default=None, help="Force a skill"),
573
+ click.option("--skills-dir", default=None, help="Custom skills directory"),
574
+ click.option("--no-skill-auto", is_flag=True, help="Disable skill auto-detection"),
575
+ click.option("--memory-dir", default=None, help="Custom memory directory"),
576
+ click.option("--mcp-config", default=None, help="MCP config JSON file"),
577
+ click.option("--resume", "-r", default=None, help="Resume a saved session"),
578
+ click.option("--prompts-dir", default=None, help="Custom prompts directory"),
579
+ click.option("--allow-all", "-A", is_flag=True, help="Allow all shell/write without prompting"),
580
+ click.option("--deny-shell", is_flag=True, help="Deny all shell commands"),
581
+ click.option("--think", type=click.Choice(["low", "medium", "high", "xhigh", "max"]),
582
+ help="Enable thinking mode"),
583
+ click.option("--anthropic", is_flag=True, help="Use Anthropic Messages API format"),
584
+ click.option("--no-stream", "-n", is_flag=True, help="Disable streaming"),
585
+ click.option("--version", "-v", is_flag=True, help="Show version and detailed info"),
586
+ ]
587
+
588
+
589
+ def _global_options(f):
590
+ for opt in reversed(_GLOBAL_OPTIONS):
591
+ f = opt(f)
592
+ return f
593
+
594
+
595
+ def _display_width(s: str) -> int:
596
+ """Calculate the terminal display width of a string (CJK ≈ 2 cells)."""
597
+ import unicodedata
598
+ w = 0
599
+ for ch in s:
600
+ ea = unicodedata.east_asian_width(ch)
601
+ w += 2 if ea in ("W", "F", "A") else 1 # A=Ambiguous, treated wide on CJK terminals
602
+ return w
603
+
604
+
605
+ def _pad(s: str, target: int) -> str:
606
+ """Pad *s* with spaces so its display width equals *target*."""
607
+ return s + " " * max(0, target - _display_width(s))
608
+
609
+
610
+ def _print_version() -> None:
611
+ """Print detailed version information and exit."""
612
+ try:
613
+ from importlib.metadata import version as pkg_version
614
+ pkg_ver = pkg_version("ata-coder")
615
+ except Exception:
616
+ pkg_ver = __version__
617
+
618
+ # Count tests dynamically
619
+ try:
620
+ test_dir = Path(__file__).parent / "tests"
621
+ test_files = list(test_dir.glob("test_*.py"))
622
+ test_count = sum(
623
+ len([l for l in f.read_text(encoding="utf-8").splitlines()
624
+ if l.strip().startswith("def test_")])
625
+ for f in test_files
626
+ )
627
+ except Exception:
628
+ test_count = "?"
629
+
630
+ # Count source files
631
+ src_files = len(list(Path(__file__).parent.glob("*.py")))
632
+
633
+ W = 40 # total content width inside borders
634
+
635
+ info = [
636
+ ("Version", pkg_ver),
637
+ ("Python", platform.python_version()),
638
+ ("Platform", platform.system()),
639
+ ("Source", f"{src_files} modules, ~{test_count} tests"),
640
+ ("Tools", f"{len(TOOL_DEFINITIONS)} built-in"),
641
+ ("License", "MIT"),
642
+ ("Repo", "github.com/jiaheng0815/ata-coder"),
643
+ ]
644
+
645
+ lines = ["┌" + "─" * (W + 2) + "┐"]
646
+ lines.append("│ " + _pad("ATA Coder", W) + "│")
647
+ lines.append("│" + " " * (W + 2) + "│")
648
+ for label, value in info:
649
+ line = f" {label}: {value}"
650
+ lines.append("│" + _pad(line, W + 2) + "│")
651
+ lines.append("└" + "─" * (W + 2) + "┘")
652
+
653
+ click.echo("\n".join(lines))
654
+
655
+
656
+ @click.group(invoke_without_command=True)
657
+ @_global_options
658
+ @click.pass_context
659
+ def cli(ctx, **kwargs):
660
+ """ATA Coder — AI-powered coding assistant.
661
+
662
+ \b
663
+ Interactive: ata
664
+ Single task: ata run "your task here"
665
+ Server: ata server
666
+ """
667
+ # Version flag — print and exit before anything else
668
+ if kwargs.get("version"):
669
+ _print_version()
670
+ return
671
+
672
+ if ctx.invoked_subcommand is not None:
673
+ return
674
+
675
+ # First-run setup BEFORE config validation
676
+ _ensure_first_run()
677
+
678
+ config = get_config()
679
+ _setup(config, kwargs)
680
+ ctx.obj = {"config": config, "kwargs": kwargs}
681
+
682
+ asyncio.run(run_interactive_async(config, **kwargs))
683
+
684
+
685
+ @cli.command("init")
686
+ def init_cmd():
687
+ """Force re-run the setup wizard (overwrites existing config)."""
688
+ _ensure_first_run(force=True)
689
+
690
+
691
+ @cli.command("run")
692
+ @click.argument("task", required=True)
693
+ @click.pass_context
694
+ def run_cmd(ctx, task):
695
+ """Run a single task."""
696
+ _ensure_first_run()
697
+ config = get_config()
698
+ kwargs = ctx.obj.get("kwargs", {}) if ctx.obj else {}
699
+ _setup(config, kwargs)
700
+ ctx.exit(asyncio.run(run_single_task_async(task, config, **kwargs)))
701
+
702
+
703
+ @cli.command("server")
704
+ @click.option("--port", "-p", type=int, default=8000, help="Server port")
705
+ @click.option("--host", default="127.0.0.1", help="Server host")
706
+ @click.pass_context
707
+ def server_cmd(ctx, port, host, **kwargs):
708
+ """Start HTTP API server."""
709
+ _ensure_first_run()
710
+ config = get_config()
711
+ group_kwargs = ctx.obj.get("kwargs", {}) if ctx.obj else {}
712
+ group_kwargs.update(kwargs)
713
+ _setup(config, group_kwargs)
714
+
715
+ if group_kwargs.get("allow_all"):
716
+ os.environ["ATA_CODER_ALLOW_ALL"] = "1"
717
+
718
+ from .model_registry import fetch_available_models
719
+ models_list = [config.llm.model]
720
+ click.echo(f"Fetching models from {config.llm.base_url} ...")
721
+ fetched = fetch_available_models(config.llm.base_url, config.llm.api_key)
722
+ if fetched:
723
+ models_list = fetched
724
+ click.echo(f" {len(models_list)} model(s): {', '.join(models_list[:10])}")
725
+ else:
726
+ click.echo(f" Could not fetch models, using configured: {config.llm.model}")
727
+
728
+ os.environ["ATA_CODER_MODELS_CACHE"] = ",".join(models_list)
729
+
730
+ from .server import create_server
731
+ srv = create_server(config, host, port)
732
+
733
+ click.echo(f"""
734
+ ATA Coder API Server
735
+ URL: http://{host}:{port}
736
+ Model: {config.llm.model}
737
+ Models: {len(models_list)} available
738
+ Tools: {len(TOOL_DEFINITIONS)}
739
+ """)
740
+
741
+ signal.signal(signal.SIGINT, signal.SIG_DFL)
742
+ try:
743
+ srv.serve_forever()
744
+ except KeyboardInterrupt:
745
+ pass
746
+ click.echo("\nServer stopped.")
747
+ srv.shutdown()
748
+
749
+
750
+ @cli.command("gui")
751
+ @click.option("--skill", "-s", default=None, help="Force a skill")
752
+ @click.option("--port", "-p", type=int, default=0, help="Server port (0=auto)")
753
+ @click.option("--no-browser", is_flag=True, help="Don't open browser")
754
+ @click.pass_context
755
+ def gui_cmd(ctx, skill, port, no_browser, **kwargs):
756
+ """Launch web-based GUI (opens browser)."""
757
+ import socket
758
+ import webbrowser
759
+ _ensure_first_run()
760
+ config = get_config()
761
+ group_kwargs = ctx.obj.get("kwargs", {}) if ctx.obj else {}
762
+ group_kwargs.update(kwargs)
763
+ _setup(config, group_kwargs)
764
+
765
+ overrides = ctx.obj.get("kwargs", {}) if ctx.obj else {}
766
+ _apply_config_overrides(config, overrides)
767
+ if skill:
768
+ config.skill = skill
769
+
770
+ # Show agent and server events, but suppress noisy internal modules
771
+ logging.getLogger().setLevel(logging.WARNING)
772
+ logging.getLogger("ata_coder.server").setLevel(logging.INFO)
773
+ logging.getLogger("ata_coder.agent").setLevel(logging.INFO)
774
+ logging.getLogger("httpx").setLevel(logging.WARNING)
775
+ logging.getLogger("ata_coder.skills").setLevel(logging.WARNING)
776
+ logging.getLogger("ata_coder.extension").setLevel(logging.WARNING)
777
+ logging.getLogger("ata_coder.skill_extension").setLevel(logging.WARNING)
778
+
779
+ # Find available port
780
+ if port == 0:
781
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
782
+ s.bind(("127.0.0.1", 0))
783
+ port = s.getsockname()[1]
784
+
785
+ from .server import create_server
786
+ srv = create_server(config, "127.0.0.1", port)
787
+ url = f"http://127.0.0.1:{port}"
788
+
789
+ click.echo(f"""
790
+ ╔══════════════════════════════════════════════════╗
791
+ ║ ATA Coder — Web GUI ║
792
+ ╠══════════════════════════════════════════════════╣
793
+ ║ URL: {url:<38}║
794
+ ║ Model: {config.llm.model:<38}║
795
+ ║ Workspace: {config.agent.workspace_dir[:36]:<36}║
796
+ ╚══════════════════════════════════════════════════╝
797
+ """)
798
+
799
+ if not no_browser:
800
+ webbrowser.open(url)
801
+
802
+ try:
803
+ srv.serve_forever()
804
+ except KeyboardInterrupt:
805
+ pass
806
+ click.echo("Server stopped.")
807
+
808
+
809
+ def main():
810
+ cli(standalone_mode=True)
811
+
812
+
813
+ if __name__ == "__main__":
814
+ sys.exit(main())