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/skills.py ADDED
@@ -0,0 +1,1014 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ Skills system — folder-based with SKILL.md manifest.
4
+
5
+ Each skill lives in its own folder under skills/:
6
+ skills/
7
+ skill-name/
8
+ SKILL.md # REQUIRED: identity, I/O schema, permissions, prompt
9
+ handler.py # optional: run(input_data) entry point
10
+ prompts/ # optional: LLM prompt templates
11
+ resources/ # optional: static data (tables, configs)
12
+ tests/ # optional: test code
13
+ requirements.txt # optional: external dependencies
14
+ README.md # optional: developer/user docs
15
+
16
+ Backward-compatible: flat .md files still work (loaded as simple skills).
17
+
18
+ Design principles:
19
+ - Single responsibility per skill
20
+ - Explicit I/O contract (call.parameters → output.schema)
21
+ - Self-contained context (no implicit conversation dependency)
22
+ - Observable execution (logs, error codes, status)
23
+ - Permission boundaries (network, filesystem, commands, domains)
24
+ """
25
+
26
+ import importlib.util
27
+ import json
28
+ import logging
29
+ import re
30
+ import sys
31
+ import traceback
32
+ from dataclasses import dataclass, field
33
+ from pathlib import Path
34
+ from typing import Any, Callable
35
+
36
+ from .utils import try_import_yaml
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ yaml, HAS_YAML = try_import_yaml()
41
+
42
+ __all__ = ["Skill", "SkillCallSpec", "SkillOutputSpec", "SkillPermissions",
43
+ "SkillManager", "get_skill_manager"]
44
+
45
+
46
+ # ═══════════════════════════════════════════════════════════════════════════════
47
+ # I/O contract types
48
+ # ═══════════════════════════════════════════════════════════════════════════════
49
+
50
+ @dataclass
51
+ class SkillCallSpec:
52
+ """How to invoke this skill."""
53
+ function: str = "" # function name
54
+ parameters: dict[str, Any] = field(default_factory=dict)
55
+ # parameters: {name: {type, description, required, default}}
56
+
57
+
58
+ @dataclass
59
+ class SkillOutputSpec:
60
+ """What this skill returns."""
61
+ format: str = "text" # text | json | status_code
62
+ schema: dict[str, Any] = field(default_factory=dict) # JSON Schema subset
63
+
64
+
65
+ @dataclass
66
+ class SkillPermissions:
67
+ """Security boundaries for this skill."""
68
+ network: bool = False # allow network access?
69
+ filesystem: str = "none" # none | read_only | read_write
70
+ allowed_commands: list[str] = field(default_factory=list)
71
+ allowed_domains: list[str] = field(default_factory=list)
72
+
73
+
74
+ # ═══════════════════════════════════════════════════════════════════════════════
75
+ # Skill data model
76
+ # ═══════════════════════════════════════════════════════════════════════════════
77
+
78
+ @dataclass
79
+ class Skill:
80
+ """A named skill with explicit I/O contract, permissions, and lifecycle."""
81
+
82
+ # Identity
83
+ name: str
84
+ version: str = "1.0.0"
85
+ description: str = ""
86
+ type: str = "skill" # skill | tool | mcp | middleware
87
+ tags: list[str] = field(default_factory=list)
88
+
89
+ # Prompt (main body of SKILL.md)
90
+ system_prompt: str = ""
91
+
92
+ # I/O contract
93
+ call: SkillCallSpec | None = None
94
+ output: SkillOutputSpec | None = None
95
+
96
+ # Triggers (for auto-detection)
97
+ triggers: list[str] = field(default_factory=list)
98
+
99
+ # Tool restrictions (empty = all tools allowed)
100
+ tools: list[str] = field(default_factory=list)
101
+
102
+ # Permissions
103
+ permissions: SkillPermissions | None = None
104
+
105
+ # Dependencies
106
+ dependencies: list[str] = field(default_factory=list)
107
+
108
+ # Model override
109
+ model: str | None = None
110
+ temperature: float | None = None
111
+
112
+ # Extension metadata
113
+ metadata: dict[str, Any] = field(default_factory=dict)
114
+
115
+ # Runtime
116
+ skill_dir: str = "" # path to skill folder
117
+ _handler: Callable | None = None # loaded handler function
118
+
119
+ # ── Serialization ────────────────────────────────────────────────────
120
+
121
+ def to_frontmatter(self) -> str:
122
+ """Export as SKILL.md format with full manifest."""
123
+ d: dict[str, Any] = {
124
+ "name": self.name,
125
+ "version": self.version,
126
+ "description": self.description,
127
+ "type": self.type,
128
+ "tags": self.tags,
129
+ }
130
+ if self.triggers:
131
+ d["triggers"] = self.triggers
132
+ if self.tools:
133
+ d["tools"] = self.tools
134
+ if self.model:
135
+ d["model"] = self.model
136
+ if self.dependencies:
137
+ d["dependencies"] = self.dependencies
138
+ if self.call:
139
+ d["call"] = {
140
+ "function": self.call.function,
141
+ "parameters": self.call.parameters,
142
+ }
143
+ if self.output:
144
+ d["output"] = {
145
+ "format": self.output.format,
146
+ }
147
+ if self.output.schema:
148
+ d["output"]["schema"] = self.output.schema
149
+ if self.permissions:
150
+ d["permissions"] = {
151
+ "network": self.permissions.network,
152
+ "filesystem": self.permissions.filesystem,
153
+ }
154
+ if self.permissions.allowed_commands:
155
+ d["permissions"]["allowed_commands"] = self.permissions.allowed_commands
156
+ if self.permissions.allowed_domains:
157
+ d["permissions"]["allowed_domains"] = self.permissions.allowed_domains
158
+ if self.metadata:
159
+ d["metadata"] = self.metadata
160
+
161
+ fm = (
162
+ yaml.dump(d, default_flow_style=False, allow_unicode=True, sort_keys=False)
163
+ if HAS_YAML
164
+ else json.dumps(d, indent=2, ensure_ascii=False)
165
+ )
166
+ body = self.system_prompt or f"# {self.name}\n\n{self.description}"
167
+ return f"---\n{fm}---\n\n{body}"
168
+
169
+ @classmethod
170
+ def from_dict(cls, d: dict[str, Any]) -> "Skill":
171
+ """Build a Skill from a flat dict (legacy JSON/YAML format)."""
172
+ call_raw = d.get("call", {}) or {}
173
+ output_raw = d.get("output", {}) or {}
174
+ perm_raw = d.get("permissions", {}) or {}
175
+
176
+ return cls(
177
+ name=d.get("name", ""),
178
+ version=d.get("version", "1.0.0"),
179
+ description=d.get("description", ""),
180
+ type=d.get("type", "skill"),
181
+ tags=d.get("tags", []),
182
+ system_prompt=d.get("system_prompt", ""),
183
+ call=SkillCallSpec(
184
+ function=call_raw.get("function", ""),
185
+ parameters=call_raw.get("parameters", {}),
186
+ ) if call_raw else None,
187
+ output=SkillOutputSpec(
188
+ format=output_raw.get("format", "text"),
189
+ schema=output_raw.get("schema", {}),
190
+ ) if output_raw else None,
191
+ triggers=d.get("triggers", []),
192
+ tools=d.get("tools", []),
193
+ permissions=SkillPermissions(
194
+ network=perm_raw.get("network", False),
195
+ filesystem=perm_raw.get("filesystem", "none"),
196
+ allowed_commands=perm_raw.get("allowed_commands", []),
197
+ allowed_domains=perm_raw.get("allowed_domains", []),
198
+ ) if perm_raw else None,
199
+ dependencies=d.get("dependencies", []),
200
+ model=d.get("model"),
201
+ temperature=d.get("temperature"),
202
+ metadata=d.get("metadata", {}),
203
+ )
204
+
205
+ @classmethod
206
+ def from_frontmatter(cls, raw: str, source: str = "unknown",
207
+ skill_dir: str = "") -> "Skill | None":
208
+ """Parse a SKILL.md file (YAML frontmatter + markdown body)."""
209
+ match = re.match(r"^---\s*\n(.*?)\n---\s*\n(.*)", raw, re.DOTALL)
210
+ if not match:
211
+ logger.warning("No YAML frontmatter in %s", source)
212
+ return None
213
+ try:
214
+ if HAS_YAML:
215
+ meta = yaml.safe_load(match.group(1))
216
+ else:
217
+ meta = json.loads(match.group(1))
218
+ except Exception as e:
219
+ logger.warning("Failed to parse frontmatter in %s: %s", source, e)
220
+ return None
221
+ if not isinstance(meta, dict):
222
+ return None
223
+
224
+ call_raw = meta.get("call", {}) or {}
225
+ output_raw = meta.get("output", {}) or {}
226
+ perm_raw = meta.get("permissions", {}) or {}
227
+
228
+ return cls(
229
+ name=meta.get("name", Path(source).stem),
230
+ version=str(meta.get("version", "1.0.0")),
231
+ description=meta.get("description", ""),
232
+ type=meta.get("type", "skill"),
233
+ tags=meta.get("tags", []),
234
+ system_prompt=match.group(2).strip(),
235
+ call=SkillCallSpec(
236
+ function=call_raw.get("function", ""),
237
+ parameters=call_raw.get("parameters", {}),
238
+ ) if call_raw else None,
239
+ output=SkillOutputSpec(
240
+ format=output_raw.get("format", "text"),
241
+ schema=output_raw.get("schema", {}),
242
+ ) if output_raw else None,
243
+ triggers=meta.get("triggers", []),
244
+ tools=meta.get("tools", []),
245
+ permissions=SkillPermissions(
246
+ network=perm_raw.get("network", False),
247
+ filesystem=perm_raw.get("filesystem", "none"),
248
+ allowed_commands=perm_raw.get("allowed_commands", []),
249
+ allowed_domains=perm_raw.get("allowed_domains", []),
250
+ ) if (perm_raw.get("network") is not None
251
+ or perm_raw.get("filesystem")
252
+ or perm_raw.get("allowed_commands")) else None,
253
+ dependencies=meta.get("dependencies", []),
254
+ model=meta.get("model"),
255
+ temperature=meta.get("temperature"),
256
+ metadata=meta.get("metadata", {}),
257
+ skill_dir=skill_dir,
258
+ )
259
+
260
+ # ── Runtime: handler ────────────────────────────────────────────────
261
+
262
+ def load_handler(self) -> bool:
263
+ """Load handler.py from the skill directory. Returns True if found."""
264
+ if not self.skill_dir:
265
+ return False
266
+ handler_path = Path(self.skill_dir) / "handler.py"
267
+ if not handler_path.exists():
268
+ return False
269
+ try:
270
+ spec = importlib.util.spec_from_file_location(
271
+ f"ata_skill_{self.name}", str(handler_path)
272
+ )
273
+ if spec is None or spec.loader is None:
274
+ return False
275
+ module = importlib.util.module_from_spec(spec)
276
+ sys.modules[spec.name] = module
277
+ spec.loader.exec_module(module)
278
+ self._handler = getattr(module, "run", None) or getattr(module, "handle", None)
279
+ if self._handler:
280
+ logger.debug("Loaded handler for skill %s", self.name)
281
+ return True
282
+ except Exception:
283
+ logger.exception("Failed to load handler for skill %s", self.name)
284
+ return False
285
+
286
+ def run_handler(self, input_data: dict[str, Any]) -> Any:
287
+ """Execute the skill's handler with structured input."""
288
+ if not self._handler:
289
+ self.load_handler()
290
+ if not self._handler:
291
+ raise RuntimeError(f"Skill {self.name} has no handler")
292
+ try:
293
+ return self._handler(input_data)
294
+ except Exception:
295
+ logger.exception("Handler failed for skill %s", self.name)
296
+ raise
297
+
298
+ @property
299
+ def has_handler(self) -> bool:
300
+ if self._handler:
301
+ return True
302
+ return bool(self.skill_dir and (Path(self.skill_dir) / "handler.py").exists())
303
+
304
+ # ── Runtime: prompts ─────────────────────────────────────────────────
305
+
306
+ def load_prompts(self) -> dict[str, str]:
307
+ """
308
+ Load all prompt templates from the prompts/ directory.
309
+ Returns {name: content} dict. Supports .md, .txt, .prompt files.
310
+ Cached after first load.
311
+ """
312
+ if not self.skill_dir:
313
+ return {}
314
+ prompts_dir = Path(self.skill_dir) / "prompts"
315
+ if not prompts_dir.is_dir():
316
+ return {}
317
+ result: dict[str, str] = {}
318
+ for fp in sorted(prompts_dir.glob("*")):
319
+ if fp.suffix in (".md", ".txt", ".prompt"):
320
+ try:
321
+ result[fp.stem] = fp.read_text(encoding="utf-8")
322
+ except Exception:
323
+ logger.warning("Failed to read prompt %s", fp)
324
+ return result
325
+
326
+ def get_prompt_template(self, name: str) -> str | None:
327
+ """
328
+ Get a specific prompt template by name (without extension).
329
+ Example: skill.get_prompt_template("system") → content of prompts/system.md
330
+ """
331
+ if not self.skill_dir:
332
+ return None
333
+ for ext in (".md", ".txt", ".prompt"):
334
+ fp = Path(self.skill_dir) / "prompts" / f"{name}{ext}"
335
+ if fp.exists():
336
+ try:
337
+ return fp.read_text(encoding="utf-8")
338
+ except Exception:
339
+ return None
340
+ return None
341
+
342
+ @property
343
+ def prompt_names(self) -> list[str]:
344
+ """List available prompt template names."""
345
+ if not self.skill_dir:
346
+ return []
347
+ prompts_dir = Path(self.skill_dir) / "prompts"
348
+ if not prompts_dir.is_dir():
349
+ return []
350
+ return sorted(
351
+ fp.stem for fp in prompts_dir.glob("*")
352
+ if fp.suffix in (".md", ".txt", ".prompt")
353
+ )
354
+
355
+ # ── Runtime: resources ───────────────────────────────────────────────
356
+
357
+ def load_resources(self) -> dict[str, Any]:
358
+ """
359
+ Load all resource files from the resources/ directory.
360
+ JSON files → parsed objects; .yaml/.yml → parsed; others → raw text.
361
+ Cached after first load.
362
+ """
363
+ if not self.skill_dir:
364
+ return {}
365
+ res_dir = Path(self.skill_dir) / "resources"
366
+ if not res_dir.is_dir():
367
+ return {}
368
+ result: dict[str, Any] = {}
369
+ for fp in sorted(res_dir.glob("*")):
370
+ if fp.name.startswith("."):
371
+ continue
372
+ try:
373
+ if fp.suffix in (".json",):
374
+ result[fp.stem] = json.loads(fp.read_text(encoding="utf-8"))
375
+ elif fp.suffix in (".yaml", ".yml") and HAS_YAML:
376
+ result[fp.stem] = yaml.safe_load(fp.read_text(encoding="utf-8"))
377
+ elif fp.suffix in (".txt", ".csv", ".tsv"):
378
+ result[fp.stem] = fp.read_text(encoding="utf-8")
379
+ elif fp.suffix in (".py",):
380
+ continue # skip Python files in resources
381
+ else:
382
+ result[fp.stem] = fp.read_text(encoding="utf-8")
383
+ except Exception:
384
+ logger.warning("Failed to load resource %s", fp)
385
+ return result
386
+
387
+ def get_resource(self, name: str) -> Any:
388
+ """
389
+ Get a specific resource by name (without extension).
390
+ Example: skill.get_resource("config") → parsed JSON from resources/config.json
391
+ """
392
+ if not self.skill_dir:
393
+ return None
394
+ res_dir = Path(self.skill_dir) / "resources"
395
+ if not res_dir.is_dir():
396
+ return None
397
+ for fp in sorted(res_dir.glob(f"{name}.*")):
398
+ if fp.name.startswith("."):
399
+ continue
400
+ try:
401
+ if fp.suffix in (".json",):
402
+ return json.loads(fp.read_text(encoding="utf-8"))
403
+ elif fp.suffix in (".yaml", ".yml") and HAS_YAML:
404
+ return yaml.safe_load(fp.read_text(encoding="utf-8"))
405
+ else:
406
+ return fp.read_text(encoding="utf-8")
407
+ except Exception:
408
+ logger.warning("Failed to read resource %s", fp)
409
+ return None
410
+
411
+ @property
412
+ def resource_names(self) -> list[str]:
413
+ """List available resource names."""
414
+ if not self.skill_dir:
415
+ return []
416
+ res_dir = Path(self.skill_dir) / "resources"
417
+ if not res_dir.is_dir():
418
+ return []
419
+ return sorted({fp.stem for fp in res_dir.glob("*") if not fp.name.startswith(".")})
420
+
421
+ # ── Runtime: README ──────────────────────────────────────────────────
422
+
423
+ def get_readme(self) -> str | None:
424
+ """Read the skill's README.md if it exists."""
425
+ if not self.skill_dir:
426
+ return None
427
+ fp = Path(self.skill_dir) / "README.md"
428
+ if fp.exists():
429
+ try:
430
+ return fp.read_text(encoding="utf-8")
431
+ except Exception:
432
+ return None
433
+ return None
434
+
435
+ # ── Runtime: dependencies ────────────────────────────────────────────
436
+
437
+ def get_dependencies(self) -> list[str]:
438
+ """Parse requirements.txt from the skill directory. Returns list of package specs."""
439
+ if not self.skill_dir:
440
+ return []
441
+ fp = Path(self.skill_dir) / "requirements.txt"
442
+ if not fp.exists():
443
+ return []
444
+ try:
445
+ lines = fp.read_text(encoding="utf-8").strip().splitlines()
446
+ return [
447
+ line.strip() for line in lines
448
+ if line.strip() and not line.strip().startswith("#")
449
+ ]
450
+ except Exception:
451
+ return []
452
+
453
+ # ── Runtime: generic file access ────────────────────────────────────
454
+
455
+ def read_file(self, relative_path: str) -> str | None:
456
+ """
457
+ Read ANY file within the skill folder.
458
+
459
+ Example: skill.read_file(".env.example") → content
460
+ skill.read_file("prompts/system_prompt.txt") → content
461
+ """
462
+ if not self.skill_dir:
463
+ return None
464
+ fp = Path(self.skill_dir) / relative_path
465
+ # Safety: prevent path traversal
466
+ try:
467
+ fp.resolve().relative_to(Path(self.skill_dir).resolve())
468
+ except ValueError:
469
+ logger.warning("Path traversal blocked: %s", relative_path)
470
+ return None
471
+ if not fp.is_file():
472
+ return None
473
+ try:
474
+ return fp.read_text(encoding="utf-8")
475
+ except Exception:
476
+ logger.warning("Failed to read %s", fp)
477
+ return None
478
+
479
+ def read_json(self, relative_path: str) -> Any:
480
+ """Read and parse a JSON file in the skill folder."""
481
+ raw = self.read_file(relative_path)
482
+ if raw is None:
483
+ return None
484
+ try:
485
+ return json.loads(raw)
486
+ except json.JSONDecodeError:
487
+ logger.warning("Invalid JSON: %s", relative_path)
488
+ return None
489
+
490
+ def list_files(self, pattern: str = "*") -> list[str]:
491
+ """
492
+ List files in the skill folder matching a glob pattern.
493
+
494
+ Example: skill.list_files("prompts/*.txt") → ["prompts/system_prompt.txt"]
495
+ """
496
+ if not self.skill_dir:
497
+ return []
498
+ base = Path(self.skill_dir)
499
+ matches = sorted(base.glob(pattern))
500
+ return [
501
+ str(m.relative_to(base)).replace("\\", "/")
502
+ for m in matches if m.is_file()
503
+ ]
504
+
505
+ def file_tree(self) -> str:
506
+ """Return a plain-text tree of the skill folder (dir/, indent, file)."""
507
+ if not self.skill_dir:
508
+ return "(no directory)"
509
+ base = Path(self.skill_dir)
510
+ lines = [base.name + "/"]
511
+ for fp in sorted(base.rglob("*")):
512
+ if any(p.startswith(".") for p in fp.parts):
513
+ continue
514
+ if fp.name == "__pycache__":
515
+ continue
516
+ depth = len(fp.relative_to(base).parts)
517
+ indent = " " * (depth - 1)
518
+ if fp.is_dir():
519
+ lines.append(f"{indent} {fp.name}/")
520
+ else:
521
+ lines.append(f"{indent} {fp.name}")
522
+ return "\n".join(lines)
523
+
524
+ # ── Alternative manifest formats ─────────────────────────────────────
525
+
526
+ @classmethod
527
+ def from_manifest_json(cls, path: str | Path) -> "Skill | None":
528
+ """Load skill from a manifest.json file."""
529
+ fp = Path(path)
530
+ if not fp.exists():
531
+ return None
532
+ try:
533
+ data = json.loads(fp.read_text(encoding="utf-8"))
534
+ except Exception as e:
535
+ logger.warning("Failed to parse %s: %s", fp, e)
536
+ return None
537
+ call_raw = data.get("call", {}) or {}
538
+ output_raw = data.get("output", {}) or {}
539
+ perm_raw = data.get("permissions", {}) or {}
540
+ return cls(
541
+ name=data.get("name", fp.parent.name),
542
+ version=data.get("version", "1.0.0"),
543
+ description=data.get("description", ""),
544
+ type=data.get("type", "skill"),
545
+ tags=data.get("tags", []),
546
+ system_prompt=data.get("system_prompt", data.get("description", "")),
547
+ call=SkillCallSpec(
548
+ function=call_raw.get("function", ""),
549
+ parameters=call_raw.get("parameters", {}),
550
+ ) if call_raw else None,
551
+ output=SkillOutputSpec(
552
+ format=output_raw.get("format", "text"),
553
+ schema=output_raw.get("schema", {}),
554
+ ) if output_raw else None,
555
+ triggers=data.get("triggers", []),
556
+ tools=data.get("tools", []),
557
+ permissions=SkillPermissions(
558
+ network=perm_raw.get("network", False),
559
+ filesystem=perm_raw.get("filesystem", "none"),
560
+ allowed_commands=perm_raw.get("allowed_commands", []),
561
+ allowed_domains=perm_raw.get("allowed_domains", []),
562
+ ) if perm_raw else None,
563
+ dependencies=data.get("dependencies", []),
564
+ model=data.get("model"),
565
+ metadata={
566
+ **data.get("metadata", {}),
567
+ **{k: data[k] for k in ("author", "license", "homepage")
568
+ if k in data and k not in data.get("metadata", {})},
569
+ },
570
+ skill_dir=str(fp.parent),
571
+ )
572
+
573
+ @classmethod
574
+ def from_skill_yaml(cls, path: str | Path) -> "Skill | None":
575
+ """Load skill from a skill.yaml or skill.yml file."""
576
+ if not HAS_YAML:
577
+ return None
578
+ fp = Path(path)
579
+ if not fp.exists():
580
+ return None
581
+ try:
582
+ data = yaml.safe_load(fp.read_text(encoding="utf-8"))
583
+ except Exception as e:
584
+ logger.warning("Failed to parse %s: %s", fp, e)
585
+ return None
586
+ if not isinstance(data, dict):
587
+ return None
588
+ return cls.from_dict({**data, "skill_dir": str(fp.parent)})
589
+
590
+ @property
591
+ def safe_name(self) -> str:
592
+ """Name safe for use as identifier."""
593
+ return re.sub(r"[^a-zA-Z0-9_]", "_", self.name)
594
+
595
+ def get_prompt(self) -> str:
596
+ """Return system prompt (alias for system_prompt for Extension compat)."""
597
+ return self.resolve_includes(self.system_prompt)
598
+
599
+ def get_tools(self) -> list[str]:
600
+ """Return tool restriction list (alias for tools for Extension compat)."""
601
+ return self.tools
602
+
603
+ def resolve_includes(self, text: str, _depth: int = 0) -> str:
604
+ """
605
+ Resolve @include directives in *text*.
606
+
607
+ Syntax:
608
+ @include path/to/file.md — inline file content (relative to skill dir)
609
+ @include prompts/system.txt — load a prompt template
610
+ @include resources/config.json — load and inline as text
611
+
612
+ The included file's content replaces the @include line. Recursive
613
+ includes are supported (max depth 5 to prevent infinite loops).
614
+
615
+ Lines without a matching file are left as-is (no error — the LLM
616
+ will see the raw directive and can ask for clarification).
617
+ """
618
+ if _depth > 5:
619
+ return text
620
+
621
+ resolved_lines: list[str] = []
622
+ for line in text.split("\n"):
623
+ stripped = line.strip()
624
+ if stripped.startswith("@include "):
625
+ rel_path = stripped[len("@include "):].strip()
626
+ included = self.read_file(rel_path)
627
+ if included is not None:
628
+ # Recursively resolve includes in the included file
629
+ included = self.resolve_includes(included, _depth + 1)
630
+ resolved_lines.append(included)
631
+ else:
632
+ # File not found — leave directive as-is so the LLM can react
633
+ resolved_lines.append(line)
634
+ else:
635
+ resolved_lines.append(line)
636
+ return "\n".join(resolved_lines)
637
+
638
+ def __repr__(self) -> str:
639
+ return f"Skill(name={self.name!r}, v{self.version}, type={self.type})"
640
+
641
+
642
+ # ═══════════════════════════════════════════════════════════════════════════════
643
+ # Skill manager
644
+ # ═══════════════════════════════════════════════════════════════════════════════
645
+
646
+ class SkillManager:
647
+ """Loads skills from folder-based skill directories + flat legacy files."""
648
+
649
+ def __init__(self, skills_dir: str | Path | None = None):
650
+ if skills_dir is None:
651
+ # Always use ~/.ata_coder/skills — seed if empty
652
+ from .settings import init_settings
653
+ try:
654
+ settings = init_settings()
655
+ skills_dir = settings.skills_dir
656
+ except Exception:
657
+ skills_dir = Path.home() / ".ata_coder" / "skills"
658
+ self.skills_dir = Path(skills_dir)
659
+ self.skills_dir.mkdir(parents=True, exist_ok=True)
660
+
661
+ self._skills: dict[str, Skill] = {}
662
+ self._active_skills: dict[str, Skill] = {}
663
+
664
+ self._load_from_directory()
665
+
666
+ # Log what we found
667
+ logger.info("Skills loaded: %d from %s", len(self._skills), self.skills_dir)
668
+
669
+ # ── Loading ─────────────────────────────────────────────────────────────
670
+
671
+ def _load_from_directory(self) -> None:
672
+ """Scan skills/ for:
673
+ 1. Subdirectories containing SKILL.md (primary format)
674
+ 2. Flat .md files (legacy — each is one skill)
675
+ 3. .json / .yaml files (legacy)
676
+ """
677
+ if not self.skills_dir.exists():
678
+ return
679
+
680
+ # ── Folder-based skills (primary) ──────────────────────────────
681
+ for d in sorted(self.skills_dir.iterdir()):
682
+ if not d.is_dir() or d.name.startswith(".") or d.name.startswith("_"):
683
+ continue
684
+ skill = None
685
+
686
+ # Priority: SKILL.md → manifest.json → skill.yaml → *.md
687
+ skill_md = d / "SKILL.md"
688
+ manifest_json = d / "manifest.json"
689
+ skill_yaml = d / "skill.yaml"
690
+ skill_yml = d / "skill.yml"
691
+
692
+ if skill_md.exists():
693
+ try:
694
+ raw = skill_md.read_text(encoding="utf-8")
695
+ skill = Skill.from_frontmatter(raw, source=str(skill_md),
696
+ skill_dir=str(d))
697
+ except Exception as e:
698
+ logger.warning("Failed to load SKILL.md from %s: %s", d, e)
699
+ elif manifest_json.exists():
700
+ skill = Skill.from_manifest_json(str(manifest_json))
701
+ elif skill_yaml.exists():
702
+ skill = Skill.from_skill_yaml(str(skill_yaml))
703
+ elif skill_yml.exists():
704
+ skill = Skill.from_skill_yaml(str(skill_yml))
705
+ else:
706
+ # Legacy fallback: first .md file in folder
707
+ md_files = sorted(d.glob("*.md"))
708
+ if md_files:
709
+ try:
710
+ raw = md_files[0].read_text(encoding="utf-8")
711
+ skill = Skill.from_frontmatter(raw, source=str(md_files[0]),
712
+ skill_dir=str(d))
713
+ except Exception as e:
714
+ logger.warning("Failed to load %s: %s", md_files[0], e)
715
+
716
+ if skill and skill.name:
717
+ self._skills[skill.name] = skill
718
+ logger.debug("Loaded skill: %s from %s/", skill.name, d.name)
719
+
720
+ # ── Flat .md files (legacy backward compat) ────────────────────
721
+ for fp in sorted(self.skills_dir.glob("*.md")):
722
+ try:
723
+ raw = fp.read_text(encoding="utf-8")
724
+ skill = Skill.from_frontmatter(raw, source=fp.name)
725
+ if skill and skill.name not in self._skills:
726
+ self._skills[skill.name] = skill
727
+ logger.debug("Loaded legacy skill: %s from %s", skill.name, fp.name)
728
+ except Exception as e:
729
+ logger.warning("Failed to load legacy skill %s: %s", fp.name, e)
730
+
731
+ # ── Legacy JSON/YAML ──────────────────────────────────────────
732
+ for fp in self.skills_dir.glob("*.json"):
733
+ try:
734
+ data = json.loads(fp.read_text(encoding="utf-8"))
735
+ for item in (data if isinstance(data, list) else [data]):
736
+ skill = Skill.from_dict(item)
737
+ if skill.name and skill.name not in self._skills:
738
+ self._skills[skill.name] = skill
739
+ except Exception as e:
740
+ logger.warning("Failed to load %s: %s", fp.name, e)
741
+
742
+ if HAS_YAML:
743
+ for fp in list(self.skills_dir.glob("*.yaml")) + list(self.skills_dir.glob("*.yml")):
744
+ try:
745
+ data = yaml.safe_load(fp.read_text(encoding="utf-8"))
746
+ for item in (data if isinstance(data, list) else [data]):
747
+ skill = Skill.from_dict(item)
748
+ if skill.name and skill.name not in self._skills:
749
+ self._skills[skill.name] = skill
750
+ except Exception as e:
751
+ logger.warning("Failed to load %s: %s", fp.name, e)
752
+
753
+ logger.debug("Loaded %d skills total", len(self._skills))
754
+
755
+ # ── Management ──────────────────────────────────────────────────────────
756
+
757
+ def list_skills(self) -> list[Skill]:
758
+ return list(self._skills.values())
759
+
760
+ def get_skill(self, name: str) -> Skill | None:
761
+ return self._skills.get(name)
762
+
763
+ def activate(self, name: str, merge: bool = True) -> Skill | None:
764
+ """Activate a skill. merge=True → multi-skill, merge=False → solo."""
765
+ skill = self._skills.get(name)
766
+ if skill:
767
+ if not merge:
768
+ self._active_skills.clear()
769
+ self._active_skills[name] = skill
770
+ logger.info("Activated: %s (active: %d)", name, len(self._active_skills))
771
+ else:
772
+ logger.warning("Skill not found: %s", name)
773
+ return skill
774
+
775
+ def deactivate(self, name: str | None = None) -> None:
776
+ """Deactivate specific skill or all."""
777
+ if name:
778
+ self._active_skills.pop(name, None)
779
+ else:
780
+ self._active_skills.clear()
781
+
782
+ @property
783
+ def active_skill(self) -> Skill | None:
784
+ """Backward-compat: first active skill."""
785
+ for s in self._active_skills.values():
786
+ return s
787
+ return None
788
+
789
+ @property
790
+ def active_skills(self) -> list[Skill]:
791
+ return list(self._active_skills.values())
792
+
793
+ def get_system_prompt(self) -> str:
794
+ """Aggregate prompts from all active skills."""
795
+ if self._active_skills:
796
+ sorted_skills = sorted(self._active_skills.values(), key=lambda s: s.name)
797
+ parts = [s.system_prompt for s in sorted_skills if s.system_prompt]
798
+ if parts:
799
+ return "\n\n".join(parts)
800
+ default = self._skills.get("general-coder")
801
+ return default.system_prompt if default else "You are an expert coding assistant."
802
+
803
+ def get_allowed_tools(self) -> list[str] | None:
804
+ """Intersection of tool restrictions from all active skills."""
805
+ restrictions: list[set[str]] = []
806
+ for skill in self._active_skills.values():
807
+ if skill.tools:
808
+ restrictions.append(set(skill.tools))
809
+ if not restrictions:
810
+ return None
811
+ allowed = restrictions[0]
812
+ for r in restrictions[1:]:
813
+ allowed &= r
814
+ return list(allowed) if allowed else None
815
+
816
+ # ── Detection ───────────────────────────────────────────────────────────
817
+
818
+ def _trigger_matches(self, trigger: str, text: str) -> bool:
819
+ t = trigger.lower()
820
+ words = t.split()
821
+ return all(w in text for w in words) if len(words) > 1 else t in text
822
+
823
+ def detect_skill(self, user_input: str) -> Skill | None:
824
+ candidates = self.detect_skills(user_input)
825
+ return candidates[0] if candidates else None
826
+
827
+ def detect_skills(self, user_input: str, max_results: int = 3) -> list[Skill]:
828
+ """Auto-detect matching skills from trigger keywords."""
829
+ user_lower = user_input.lower()
830
+ candidates: list[tuple[int, Skill]] = []
831
+ for skill in self._skills.values():
832
+ if not skill.triggers:
833
+ continue
834
+ score = sum(
835
+ len(t.split())
836
+ for t in skill.triggers
837
+ if self._trigger_matches(t, user_lower)
838
+ )
839
+ if score > 0:
840
+ candidates.append((score, skill))
841
+ if not candidates:
842
+ return []
843
+ candidates.sort(key=lambda x: (-x[0], 1 if x[1].name == "general-coder" else 0))
844
+ result = [skill for _, skill in candidates[:max_results]]
845
+ if result and result[0].name == "general-coder" and len(result) > 1:
846
+ result = result[1:] + [result[:1]]
847
+ return result
848
+
849
+ def detect_skills_smart(self, user_input: str, max_results: int = 3,
850
+ llm_client=None) -> list[tuple[Skill, float]]:
851
+ """Smart skill detection with LLM-based classification.
852
+
853
+ Uses keyword matching as first pass, then LLM classification for
854
+ ambiguous cases (multiple skills with similar scores, or low confidence).
855
+
856
+ Args:
857
+ user_input: The user's task/query
858
+ max_results: Maximum number of skills to return
859
+ llm_client: Optional LLM client for smart classification
860
+
861
+ Returns:
862
+ List of (Skill, confidence) tuples sorted by confidence descending
863
+ """
864
+ # Phase 1: Keyword-based scoring (fast, no API call)
865
+ keyword_results = self.detect_skills(user_input, max_results=5)
866
+
867
+ if not keyword_results:
868
+ # No keyword match at all — try LLM if available, else default
869
+ if llm_client:
870
+ skill_name = self._llm_classify(user_input, llm_client)
871
+ if skill_name and skill_name in self._skills:
872
+ skill = self._skills[skill_name]
873
+ return [(skill, 0.7)]
874
+ default = self._skills.get("general-coder")
875
+ return [(default, 0.3)] if default else []
876
+
877
+ # Phase 2: If we have 1 clear winner (score gap > 2x), use it
878
+ if len(keyword_results) == 1:
879
+ return [(keyword_results[0], 0.85)]
880
+
881
+ # Calculate score-based confidences
882
+ scores = {}
883
+ for skill in keyword_results:
884
+ triggers = getattr(skill, 'triggers', []) or []
885
+ scores[skill.name] = sum(
886
+ len(t.split()) for t in triggers
887
+ if self._trigger_matches(t, user_input.lower())
888
+ )
889
+
890
+ top_score = max(scores.values()) if scores else 1
891
+ ranked = []
892
+ for skill in keyword_results:
893
+ conf = min(0.9, scores.get(skill.name, 1) / max(top_score, 1))
894
+ ranked.append((skill, round(conf, 2)))
895
+
896
+ # Phase 3: LLM refinement for ambiguous cases
897
+ # If top 2 skills are within 30% confidence of each other, use LLM
898
+ if len(ranked) >= 2 and ranked[0][1] - ranked[1][1] < 0.3:
899
+ if llm_client:
900
+ skill_name = self._llm_classify(user_input, llm_client)
901
+ if skill_name and skill_name in self._skills:
902
+ skill = self._skills[skill_name]
903
+ # Insert LLM-chosen skill at top
904
+ ranked.insert(0, (skill, 0.8))
905
+ # Deduplicate
906
+ seen = set()
907
+ deduped = []
908
+ for s, c in ranked:
909
+ if s.name not in seen:
910
+ seen.add(s.name)
911
+ deduped.append((s, c))
912
+ ranked = deduped
913
+
914
+ ranked.sort(key=lambda x: -x[1])
915
+ return ranked[:max_results]
916
+
917
+ def _llm_classify(self, user_input: str, llm_client) -> str | None:
918
+ """Use a cheap LLM call to classify which skill best fits the task."""
919
+ skill_list = "\n".join(
920
+ f"- {s.name}: {s.description[:100]}"
921
+ for s in self._skills.values()
922
+ if s.name != "general-coder"
923
+ )
924
+ prompt = (
925
+ "You are a task router. Given a user's request, pick the SINGLE "
926
+ "best-matching skill from the list below. Reply with ONLY the skill "
927
+ "name, nothing else.\n\n"
928
+ f"Skills:\n{skill_list}\n\n"
929
+ f"User request: {user_input[:500]}\n\n"
930
+ "Best skill name:"
931
+ )
932
+ try:
933
+ msgs = [
934
+ {"role": "system", "content": "You are a skill router. Reply with only the skill name."},
935
+ {"role": "user", "content": prompt},
936
+ ]
937
+ # Use non-streaming call with minimal tokens
938
+ response = llm_client.chat(msgs, system_prompt="Reply with only one skill name.")
939
+ name = response.strip().lower().split("\n")[0].strip().strip('"').strip("'")
940
+ # Validate against known skills
941
+ for sname in self._skills:
942
+ if sname.lower() in name or name in sname.lower():
943
+ return sname
944
+ return None
945
+ except Exception:
946
+ return None
947
+
948
+ # ── Execution ───────────────────────────────────────────────────────────
949
+
950
+ def execute_skill(self, name: str, input_data: dict[str, Any]) -> dict[str, Any]:
951
+ """
952
+ Execute a skill's handler with structured input.
953
+ Returns {success, output, error, status_code}.
954
+ """
955
+ skill = self._skills.get(name)
956
+ if not skill:
957
+ return {"success": False, "output": None, "error": f"Skill not found: {name}", "status_code": 404}
958
+ if not skill.has_handler:
959
+ return {"success": False, "output": None, "error": f"Skill {name} has no handler", "status_code": 501}
960
+ try:
961
+ result = skill.run_handler(input_data)
962
+ return {"success": True, "output": result, "error": None, "status_code": 200}
963
+ except Exception as e:
964
+ return {
965
+ "success": False,
966
+ "output": None,
967
+ "error": f"{type(e).__name__}: {e}",
968
+ "status_code": 500,
969
+ "traceback": traceback.format_exc(),
970
+ }
971
+
972
+ # ── Persistence ─────────────────────────────────────────────────────────
973
+
974
+ def save_skill(self, skill: Skill) -> Path:
975
+ """Save a skill as a folder with SKILL.md."""
976
+ skill_dir = self.skills_dir / skill.name
977
+ skill_dir.mkdir(parents=True, exist_ok=True)
978
+ fp = skill_dir / "SKILL.md"
979
+ fp.write_text(skill.to_frontmatter(), encoding="utf-8")
980
+ self._skills[skill.name] = skill
981
+ return skill_dir
982
+
983
+ def delete_skill(self, name: str) -> bool:
984
+ """Delete a skill folder and all contents."""
985
+ import shutil
986
+ skill_dir = self.skills_dir / name
987
+ if skill_dir.exists() and skill_dir.is_dir():
988
+ shutil.rmtree(skill_dir, ignore_errors=True)
989
+ self._skills.pop(name, None)
990
+ logger.info("Deleted skill folder: %s", name)
991
+ return True
992
+ # Legacy flat files
993
+ for ext in (".md", ".json", ".yaml", ".yml"):
994
+ fp = self.skills_dir / f"{name}{ext}"
995
+ if fp.exists():
996
+ fp.unlink()
997
+ self._skills.pop(name, None)
998
+ return True
999
+ return False
1000
+
1001
+
1002
+ # ═══════════════════════════════════════════════════════════════════════════════
1003
+ # Global singleton
1004
+ # ═══════════════════════════════════════════════════════════════════════════════
1005
+
1006
+ _skill_manager: SkillManager | None = None
1007
+
1008
+
1009
+ def get_skill_manager(skills_dir: str | None = None) -> SkillManager:
1010
+ """Get the global SkillManager singleton."""
1011
+ global _skill_manager
1012
+ if _skill_manager is None:
1013
+ _skill_manager = SkillManager(skills_dir)
1014
+ return _skill_manager