dulus 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. agent.py +363 -0
  2. backend/__init__.py +63 -0
  3. backend/compressor.py +261 -0
  4. backend/context.py +329 -0
  5. backend/githook.py +166 -0
  6. backend/marketplace.py +141 -0
  7. backend/mempalace_bridge.py +182 -0
  8. backend/personas.py +297 -0
  9. backend/plugins.py +222 -0
  10. backend/server.py +411 -0
  11. backend/tasks.py +213 -0
  12. batch_api.py +307 -0
  13. checkpoint/__init__.py +27 -0
  14. checkpoint/hooks.py +90 -0
  15. checkpoint/store.py +314 -0
  16. checkpoint/types.py +80 -0
  17. claude_code_watcher.py +214 -0
  18. clipboard_utils.py +246 -0
  19. cloudsave.py +159 -0
  20. common.py +177 -0
  21. compaction.py +378 -0
  22. config.py +180 -0
  23. context.py +241 -0
  24. dulus-0.2.0.dist-info/METADATA +600 -0
  25. dulus-0.2.0.dist-info/RECORD +101 -0
  26. dulus-0.2.0.dist-info/WHEEL +5 -0
  27. dulus-0.2.0.dist-info/entry_points.txt +2 -0
  28. dulus-0.2.0.dist-info/licenses/LICENSE +674 -0
  29. dulus-0.2.0.dist-info/licenses/license_manager.py +187 -0
  30. dulus-0.2.0.dist-info/top_level.txt +36 -0
  31. dulus.py +8455 -0
  32. dulus_gui.py +331 -0
  33. dulus_mcp/__init__.py +43 -0
  34. dulus_mcp/client.py +546 -0
  35. dulus_mcp/config.py +133 -0
  36. dulus_mcp/tools.py +131 -0
  37. dulus_mcp/types.py +124 -0
  38. gui/__init__.py +18 -0
  39. gui/agent_bridge.py +283 -0
  40. gui/chat_widget.py +448 -0
  41. gui/main_window.py +485 -0
  42. gui/personas.py +230 -0
  43. gui/session_utils.py +189 -0
  44. gui/settings_dialog.py +146 -0
  45. gui/sidebar.py +515 -0
  46. gui/tasks_view.py +499 -0
  47. gui/themes.py +256 -0
  48. gui/tool_panel.py +94 -0
  49. input.py +1030 -0
  50. license_manager.py +187 -0
  51. memory/__init__.py +93 -0
  52. memory/audit.py +51 -0
  53. memory/consolidator.py +312 -0
  54. memory/context.py +270 -0
  55. memory/offload.py +148 -0
  56. memory/palace.py +127 -0
  57. memory/scan.py +146 -0
  58. memory/sessions.py +100 -0
  59. memory/store.py +395 -0
  60. memory/tools.py +408 -0
  61. memory/types.py +114 -0
  62. memory/vector_search.py +92 -0
  63. multi_agent/__init__.py +23 -0
  64. multi_agent/subagent.py +501 -0
  65. multi_agent/tools.py +393 -0
  66. offload_helper.py +183 -0
  67. plugin/__init__.py +22 -0
  68. plugin/autoadapter.py +1641 -0
  69. plugin/loader.py +156 -0
  70. plugin/recommend.py +211 -0
  71. plugin/store.py +387 -0
  72. plugin/types.py +147 -0
  73. providers.py +3750 -0
  74. skill/__init__.py +14 -0
  75. skill/builtin.py +100 -0
  76. skill/clawhub.py +270 -0
  77. skill/executor.py +66 -0
  78. skill/loader.py +199 -0
  79. skill/tools.py +110 -0
  80. skills.py +14 -0
  81. spinner.py +42 -0
  82. string_utils.py +42 -0
  83. subagent.py +11 -0
  84. task/__init__.py +12 -0
  85. task/store.py +199 -0
  86. task/tools.py +265 -0
  87. task/types.py +92 -0
  88. tmux_offloader.py +177 -0
  89. tmux_tools.py +410 -0
  90. tool_registry.py +214 -0
  91. tools.py +2694 -0
  92. ui/__init__.py +1 -0
  93. ui/input.py +464 -0
  94. ui/render.py +272 -0
  95. voice/__init__.py +56 -0
  96. voice/keyterms.py +179 -0
  97. voice/recorder.py +263 -0
  98. voice/stt.py +408 -0
  99. voice/tts.py +570 -0
  100. webchat.py +432 -0
  101. webchat_server.py +1761 -0
skill/__init__.py ADDED
@@ -0,0 +1,14 @@
1
+ """skill package — reusable prompt templates (skills)."""
2
+ from .loader import ( # noqa: F401
3
+ SkillDef,
4
+ load_skills,
5
+ find_skill,
6
+ substitute_arguments,
7
+ register_builtin_skill,
8
+ _parse_skill_file,
9
+ _parse_list_field,
10
+ )
11
+ from .executor import execute_skill # noqa: F401
12
+
13
+ # Importing builtin registers the built-in skills
14
+ from . import builtin as _builtin # noqa: F401
skill/builtin.py ADDED
@@ -0,0 +1,100 @@
1
+ """Built-in skills that ship with dulus."""
2
+ from __future__ import annotations
3
+
4
+ from .loader import SkillDef, register_builtin_skill
5
+
6
+ # ── /commit ────────────────────────────────────────────────────────────────
7
+
8
+ _COMMIT_PROMPT = """\
9
+ Review the current git state and create a well-structured commit.
10
+
11
+ ## Steps
12
+
13
+ 1. Run `git status` and `git diff --staged` to see what is staged.
14
+ - If nothing is staged, run `git diff` to see unstaged changes, then stage relevant files.
15
+ 2. Analyze the changes:
16
+ - Summarize the nature of the change (feature, bug fix, refactor, docs, etc.)
17
+ - Write a concise commit title (≤72 chars) focusing on *why*, not just *what*.
18
+ - If multiple logical changes exist, ask the user whether to split them.
19
+ 3. Create the commit:
20
+ ```
21
+ git commit -m "<title>"
22
+ ```
23
+ If additional context is needed, add a body separated by a blank line.
24
+ 4. Print the commit hash and summary when done.
25
+
26
+ **Rules:**
27
+ - Never use `--no-verify`.
28
+ - Never commit files that likely contain secrets (.env, credentials, keys).
29
+ - Prefer imperative mood in the title: "Add X", "Fix Y", "Refactor Z".
30
+
31
+ User context: $ARGUMENTS
32
+ """
33
+
34
+ _REVIEW_PROMPT = """\
35
+ Review the code or pull request and provide structured feedback.
36
+
37
+ ## Steps
38
+
39
+ 1. Understand the scope:
40
+ - If a PR number or URL is given in $ARGUMENTS, use `gh pr view $ARGUMENTS --patch` to get the diff.
41
+ - Otherwise, use `git diff main...HEAD` (or `git diff HEAD~1`) for local changes.
42
+ 2. Analyze the diff:
43
+ - Correctness: Are there bugs, edge cases, or logic errors?
44
+ - Security: Injection, auth issues, exposed secrets, unsafe operations?
45
+ - Performance: N+1 queries, unnecessary allocations, blocking calls?
46
+ - Style: Does it follow existing conventions in the codebase?
47
+ - Tests: Are new behaviors tested? Do existing tests cover the change?
48
+ 3. Write a structured review:
49
+ ```
50
+ ## Summary
51
+ One-line overview of what the change does.
52
+
53
+ ## Issues
54
+ - [CRITICAL/MAJOR/MINOR] Description and location
55
+
56
+ ## Suggestions
57
+ - Nice-to-have improvements
58
+
59
+ ## Verdict
60
+ APPROVE / REQUEST CHANGES / COMMENT
61
+ ```
62
+ 4. If changes are needed, list specific file:line references.
63
+
64
+ User context: $ARGUMENTS
65
+ """
66
+
67
+
68
+ def _register_builtins() -> None:
69
+ register_builtin_skill(SkillDef(
70
+ name="commit",
71
+ description="Review staged changes and create a well-structured git commit",
72
+ triggers=["/commit"],
73
+ tools=["Bash", "Read"],
74
+ prompt=_COMMIT_PROMPT,
75
+ file_path="<builtin>",
76
+ when_to_use="Use when the user wants to commit changes. Triggers: '/commit', 'commit changes', 'make a commit'.",
77
+ argument_hint="[optional context]",
78
+ arguments=[],
79
+ user_invocable=True,
80
+ context="inline",
81
+ source="builtin",
82
+ ))
83
+
84
+ register_builtin_skill(SkillDef(
85
+ name="review",
86
+ description="Review code changes or a pull request and provide structured feedback",
87
+ triggers=["/review", "/review-pr"],
88
+ tools=["Bash", "Read", "Grep"],
89
+ prompt=_REVIEW_PROMPT,
90
+ file_path="<builtin>",
91
+ when_to_use="Use when the user wants a code review. Triggers: '/review', '/review-pr', 'review this PR'.",
92
+ argument_hint="[PR number or URL]",
93
+ arguments=["pr"],
94
+ user_invocable=True,
95
+ context="inline",
96
+ source="builtin",
97
+ ))
98
+
99
+
100
+ _register_builtins()
skill/clawhub.py ADDED
@@ -0,0 +1,270 @@
1
+ """ClawHub + local Anthropic skill importer for Dulus.
2
+
3
+ Sources:
4
+ - LOCAL : ~/.claude/plugins/marketplaces/claude-plugins-official/ (Anthropic, on-disk)
5
+ - AWESOME : ~/.claude/plugins/marketplaces/alireza-claude-skills/ (alirezarezvani/claude-skills, ~235 skills across 9 domains)
6
+ - CLAWHUB : https://clawhub.ai (community, 52k+ skills, via API)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import re
12
+ import urllib.request
13
+ import urllib.parse
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ # ── Paths ──────────────────────────────────────────────────────────────────
18
+
19
+ DULUS_SKILLS_DIR = Path.home() / ".dulus" / "skills"
20
+ ANTHROPIC_PLUGINS = (
21
+ Path.home() / ".claude" / "plugins" / "marketplaces" / "claude-plugins-official"
22
+ )
23
+ AWESOME_SKILLS = (
24
+ Path.home() / ".claude" / "plugins" / "marketplaces" / "alireza-claude-skills"
25
+ )
26
+
27
+ # ── ClawHub API (Convex HTTP) ──────────────────────────────────────────────
28
+ # TODO: reverse-engineer exact endpoint from clawhub.ai/openclaw/clawhub repo
29
+ CLAWHUB_API_BASE = "https://clawhub.ai" # placeholder
30
+ CLAWHUB_SEARCH = f"{CLAWHUB_API_BASE}/api/search" # placeholder
31
+ CLAWHUB_GET = f"{CLAWHUB_API_BASE}/api/skill" # placeholder — /api/skill/<slug>
32
+
33
+
34
+ # ── LOCAL (Anthropic marketplace on disk) ─────────────────────────────────
35
+
36
+ def list_local(query: Optional[str] = None) -> list[dict]:
37
+ """Return all SKILL.md entries from local marketplaces (Anthropic + Awesome Skills)."""
38
+ skills = []
39
+ q = query.lower() if query else None
40
+
41
+ # Anthropic: .../plugins/<plugin>/skills/<skill>/SKILL.md
42
+ plugins_dir = ANTHROPIC_PLUGINS / "plugins"
43
+ external_dir = ANTHROPIC_PLUGINS / "external_plugins"
44
+ for base, prefix in [(plugins_dir, ""), (external_dir, "external/")]:
45
+ if not base.exists():
46
+ continue
47
+ for skill_md in sorted(base.glob("*/skills/*/SKILL.md")):
48
+ parts = skill_md.parts
49
+ plugin = parts[-4]
50
+ skill = parts[-2]
51
+ meta = _parse_frontmatter(skill_md.read_text(encoding="utf-8"))
52
+ desc = meta.get("description", "")
53
+ id_str = f"{prefix}{plugin}/{skill}"
54
+
55
+ if q and q not in id_str.lower() and q not in desc.lower():
56
+ continue
57
+
58
+ skills.append({
59
+ "id": id_str,
60
+ "plugin": plugin,
61
+ "skill": skill,
62
+ "description": desc,
63
+ "path": str(skill_md),
64
+ "source": "anthropic",
65
+ })
66
+
67
+ # alirezarezvani/claude-skills — ~235 skills nested under domain folders
68
+ # (engineering/, marketing-skill/, product-team/, etc.). Skip top-level
69
+ # docs / scaffolding folders that aren't real skills.
70
+ _AWESOME_EXCLUDE = {"docs", "documentation", "tests", "scripts", "templates", "standards", "eval-workspace"}
71
+ if AWESOME_SKILLS.exists():
72
+ for skill_md in sorted(AWESOME_SKILLS.glob("**/SKILL.md")):
73
+ # Look only at parts RELATIVE to the marketplace root, otherwise
74
+ # the home dir component ".claude" trips the dot-prefix filter.
75
+ try:
76
+ rel_parts = skill_md.relative_to(AWESOME_SKILLS).parts
77
+ except ValueError:
78
+ continue
79
+ # Skip dot-prefixed folders (.gemini/, .claude/, .codex/, etc. — tool configs that
80
+ # mirror the canonical skills) and excluded scaffolding folders.
81
+ if any(p.startswith(".") for p in rel_parts):
82
+ continue
83
+ if _AWESOME_EXCLUDE.intersection(rel_parts):
84
+ continue
85
+
86
+ skill = skill_md.parent.name
87
+ try:
88
+ raw = skill_md.read_text(encoding="utf-8")
89
+ except (FileNotFoundError, OSError):
90
+ continue
91
+ meta = _parse_frontmatter(raw)
92
+ desc = meta.get("description", "")
93
+ # Encode the domain path (e.g. "engineering/foo") so skills with
94
+ # the same name in different domains don't collide.
95
+ try:
96
+ rel = skill_md.parent.relative_to(AWESOME_SKILLS).as_posix()
97
+ except ValueError:
98
+ rel = skill
99
+ id_str = f"awesome/{rel}"
100
+
101
+ if q and q not in id_str.lower() and q not in desc.lower():
102
+ continue
103
+
104
+ skills.append({
105
+ "id": id_str,
106
+ "plugin": "awesome",
107
+ "skill": skill,
108
+ "description": desc,
109
+ "path": str(skill_md),
110
+ "source": "awesome",
111
+ })
112
+
113
+
114
+ return skills
115
+
116
+
117
+ def get_local(slug: str) -> Optional[dict]:
118
+ """Find a local skill by its id (plugin/skill or external/plugin/skill)."""
119
+ for s in list_local():
120
+ if s["id"] == slug or s["skill"] == slug:
121
+ return s
122
+ return None
123
+
124
+
125
+ def install_local(slug: str) -> tuple[bool, str]:
126
+ """Copy a local Anthropic skill (SKILL.md + all support files) into ~/.dulus/skills/<name>/"""
127
+ import shutil
128
+ entry = get_local(slug)
129
+ if not entry:
130
+ return False, f"Skill '{slug}' not found in local marketplaces (Anthropic / Awesome)."
131
+
132
+ skill_dir = Path(entry["path"]).parent # dir containing SKILL.md + support files
133
+ name = entry["skill"]
134
+ dest_dir = DULUS_SKILLS_DIR / name
135
+ dest_dir.mkdir(parents=True, exist_ok=True)
136
+
137
+ # Copy all files from the skill directory
138
+ copied = []
139
+ for src in skill_dir.rglob("*"):
140
+ if src.is_file():
141
+ rel = src.relative_to(skill_dir)
142
+ dst = dest_dir / rel
143
+ dst.parent.mkdir(parents=True, exist_ok=True)
144
+ shutil.copy2(src, dst)
145
+ copied.append(str(rel))
146
+
147
+ # Rewrite SKILL.md with Dulus frontmatter prepended
148
+ skill_md = dest_dir / "SKILL.md"
149
+ if skill_md.exists():
150
+ raw = skill_md.read_text(encoding="utf-8")
151
+ body = _strip_frontmatter(raw)
152
+ skill_md.write_text(_dulus_frontmatter(entry) + body, encoding="utf-8")
153
+
154
+ return True, f"Installed '{name}' → {dest_dir} ({len(copied)} files: {', '.join(copied[:5])}{'...' if len(copied)>5 else ''})"
155
+
156
+
157
+ # ── CLAWHUB (remote) ───────────────────────────────────────────────────────
158
+
159
+ def search_clawhub(query: str, limit: int = 10) -> list[dict]:
160
+ """Search ClawHub for skills matching query.
161
+ TODO: fill in real Convex endpoint once reversed.
162
+ """
163
+ # PLACEHOLDER — returns empty until endpoint is confirmed
164
+ _ = query, limit
165
+ return []
166
+
167
+
168
+ def install_clawhub(slug: str) -> tuple[bool, str]:
169
+ """Download a skill from ClawHub by slug and save to ~/.dulus/skills/.
170
+ TODO: fill in real endpoint.
171
+ """
172
+ # PLACEHOLDER
173
+ return False, f"ClawHub API endpoint not yet mapped. Try: /skill get local/{slug}"
174
+
175
+
176
+ # ── Installed skills ───────────────────────────────────────────────────────
177
+
178
+ def list_installed(query: Optional[str] = None) -> list[dict]:
179
+ """Return skills already saved in ~/.dulus/skills/."""
180
+ DULUS_SKILLS_DIR.mkdir(parents=True, exist_ok=True)
181
+ skills = []
182
+ seen = set()
183
+ q = query.lower() if query else None
184
+
185
+ # New format: subdirs with SKILL.md
186
+ for f in sorted(DULUS_SKILLS_DIR.glob("*/SKILL.md")):
187
+ name = f.parent.name
188
+ meta = _parse_frontmatter(f.read_text(encoding="utf-8"))
189
+ desc = meta.get("description", "")
190
+
191
+ if q and q not in name.lower() and q not in desc.lower():
192
+ continue
193
+
194
+ files = list(f.parent.rglob("*"))
195
+ skills.append({
196
+ "name": name,
197
+ "description": desc,
198
+ "source": meta.get("clawhub_source", "unknown"),
199
+ "path": str(f.parent),
200
+ "files": len([x for x in files if x.is_file()]),
201
+ })
202
+ seen.add(name)
203
+
204
+ # Old format: flat .md files
205
+ for f in sorted(DULUS_SKILLS_DIR.glob("*.md")):
206
+ name = f.stem
207
+ if name not in seen:
208
+ meta = _parse_frontmatter(f.read_text(encoding="utf-8"))
209
+ desc = meta.get("description", "")
210
+
211
+ if q and q not in name.lower() and q not in desc.lower():
212
+ continue
213
+
214
+ skills.append({
215
+ "name": name,
216
+ "description": desc,
217
+ "source": meta.get("clawhub_source", "unknown"),
218
+ "path": str(f),
219
+ "files": 1,
220
+ })
221
+ return skills
222
+
223
+
224
+ def read_skill(name: str) -> Optional[str]:
225
+ """Return the body (no frontmatter) of an installed skill."""
226
+ # New format: subdirectory with SKILL.md
227
+ subdir = DULUS_SKILLS_DIR / name / "SKILL.md"
228
+ if subdir.exists():
229
+ raw = subdir.read_text(encoding="utf-8")
230
+ return _strip_frontmatter(raw)
231
+ # Old format: flat .md file
232
+ path = DULUS_SKILLS_DIR / f"{name}.md"
233
+ if path.exists():
234
+ raw = path.read_text(encoding="utf-8")
235
+ return _strip_frontmatter(raw)
236
+ # Fuzzy match
237
+ matches = list(DULUS_SKILLS_DIR.glob(f"*{name}*/SKILL.md")) + list(DULUS_SKILLS_DIR.glob(f"*{name}*.md"))
238
+ if not matches:
239
+ return None
240
+ raw = matches[0].read_text(encoding="utf-8")
241
+ return _strip_frontmatter(raw)
242
+
243
+
244
+ # ── Helpers ───────────────────────────────────────────────────────────────
245
+
246
+ def _parse_frontmatter(text: str) -> dict:
247
+ m = re.match(r"^---\n(.*?)\n---\n?", text, re.DOTALL)
248
+ if not m:
249
+ return {}
250
+ result = {}
251
+ for line in m.group(1).splitlines():
252
+ if ":" in line:
253
+ k, _, v = line.partition(":")
254
+ result[k.strip()] = v.strip()
255
+ return result
256
+
257
+
258
+ def _strip_frontmatter(text: str) -> str:
259
+ return re.sub(r"^---\n.*?\n---\n?", "", text, count=1, flags=re.DOTALL).strip()
260
+
261
+
262
+ def _dulus_frontmatter(entry: dict) -> str:
263
+ return (
264
+ f"---\n"
265
+ f"name: {entry['skill']}\n"
266
+ f"description: {entry.get('description', '')}\n"
267
+ f"clawhub_source: {entry.get('source', 'anthropic')}\n"
268
+ f"triggers: [/{entry['skill']}]\n"
269
+ f"---\n\n"
270
+ )
skill/executor.py ADDED
@@ -0,0 +1,66 @@
1
+ """Skill execution: inline (current conversation) or forked (sub-agent)."""
2
+ from __future__ import annotations
3
+
4
+ from typing import Generator
5
+
6
+ from .loader import SkillDef, substitute_arguments
7
+
8
+
9
+ def execute_skill(
10
+ skill: SkillDef,
11
+ args: str,
12
+ state,
13
+ config: dict,
14
+ system_prompt: str,
15
+ ) -> Generator:
16
+ """Execute a skill.
17
+
18
+ If skill.context == "fork", runs as an isolated sub-agent and yields its events.
19
+ Otherwise (inline), injects the rendered prompt into the current agent loop.
20
+
21
+ Args:
22
+ skill: SkillDef to execute
23
+ args: raw argument string from user (after the trigger word)
24
+ state: AgentState
25
+ config: config dict (may contain _depth, model, etc.)
26
+ system_prompt: current system prompt string
27
+ Yields:
28
+ agent events (TextChunk, ToolStart, ToolEnd, TurnDone, …)
29
+ """
30
+ rendered = substitute_arguments(skill.prompt, args, skill.arguments)
31
+ message = f"[Skill: {skill.name}]\n\n{rendered}"
32
+
33
+ if skill.context == "fork":
34
+ yield from _execute_forked(skill, message, config, system_prompt)
35
+ else:
36
+ yield from _execute_inline(message, state, config, system_prompt)
37
+
38
+
39
+ def _execute_inline(message: str, state, config: dict, system_prompt: str) -> Generator:
40
+ """Run skill prompt inline in the current conversation."""
41
+ import agent as _agent
42
+ yield from _agent.run(message, state, config, system_prompt)
43
+
44
+
45
+ def _execute_forked(
46
+ skill: SkillDef,
47
+ message: str,
48
+ config: dict,
49
+ system_prompt: str,
50
+ ) -> Generator:
51
+ """Run skill as an isolated sub-agent (separate conversation context)."""
52
+ import agent as _agent
53
+
54
+ # Build a sub-agent config with depth tracking
55
+ depth = config.get("_depth", 0) + 1
56
+ sub_config = {**config, "_depth": depth, "_system_prompt": system_prompt}
57
+ if skill.model:
58
+ sub_config["model"] = skill.model
59
+
60
+ # Restrict tools if skill specifies allowed-tools
61
+ if skill.tools:
62
+ sub_config["_allowed_tools"] = skill.tools
63
+
64
+ # Run in fresh state (no shared history)
65
+ sub_state = _agent.AgentState()
66
+ yield from _agent.run(message, sub_state, sub_config, system_prompt)
skill/loader.py ADDED
@@ -0,0 +1,199 @@
1
+ """Skill loading: parse markdown files with YAML frontmatter into SkillDef objects."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+
10
+ @dataclass
11
+ class SkillDef:
12
+ name: str
13
+ description: str
14
+ triggers: list[str] # ["/commit", "commit changes"]
15
+ tools: list[str] # ["Bash", "Read"] (allowed-tools)
16
+ prompt: str # full prompt body after frontmatter
17
+ file_path: str
18
+ # Enhanced fields
19
+ when_to_use: str = "" # when Claude should auto-invoke this skill
20
+ argument_hint: str = "" # e.g. "[branch] [description]"
21
+ arguments: list[str] = field(default_factory=list) # named arg names
22
+ model: str = "" # model override
23
+ user_invocable: bool = True # appears in /skills list
24
+ context: str = "inline" # "inline" or "fork" (fork = sub-agent)
25
+ source: str = "user" # "user", "project", "builtin"
26
+
27
+
28
+ # ── Directory paths ────────────────────────────────────────────────────────
29
+
30
+ def _get_skill_paths() -> list[Path]:
31
+ return [
32
+ Path.cwd() / ".dulus-context" / "skills", # project-level (priority)
33
+ Path.home() / ".dulus" / "skills", # user-level
34
+ ]
35
+
36
+
37
+ # ── List field parser ──────────────────────────────────────────────────────
38
+
39
+ def _parse_list_field(value: str) -> list[str]:
40
+ """Parse YAML-like list: ``[a, b, c]`` or ``"a, b, c"``."""
41
+ value = value.strip()
42
+ if value.startswith("[") and value.endswith("]"):
43
+ value = value[1:-1]
44
+ return [item.strip().strip('"').strip("'") for item in value.split(",") if item.strip()]
45
+
46
+
47
+ # ── Single-file parser ─────────────────────────────────────────────────────
48
+
49
+ def _parse_skill_file(path: Path, source: str = "user") -> Optional[SkillDef]:
50
+ """Parse a markdown file with ``---`` frontmatter into a SkillDef.
51
+
52
+ Frontmatter fields:
53
+ name, description, triggers, tools / allowed-tools,
54
+ when_to_use, argument-hint, arguments, model,
55
+ user-invocable, context
56
+ """
57
+ try:
58
+ text = path.read_text(encoding="utf-8")
59
+ except Exception:
60
+ return None
61
+
62
+ if not text.startswith("---"):
63
+ return None
64
+
65
+ parts = text.split("---", 2)
66
+ if len(parts) < 3:
67
+ return None
68
+
69
+ frontmatter_raw = parts[1].strip()
70
+ prompt = parts[2].strip()
71
+
72
+ fields: dict[str, str] = {}
73
+ for line in frontmatter_raw.splitlines():
74
+ line = line.strip()
75
+ if not line or ":" not in line:
76
+ continue
77
+ key, _, val = line.partition(":")
78
+ fields[key.strip().lower()] = val.strip()
79
+
80
+ name = fields.get("name", "")
81
+ if not name:
82
+ return None
83
+
84
+ # allowed-tools wins over tools if present
85
+ tools_raw = fields.get("allowed-tools", fields.get("tools", ""))
86
+ tools = _parse_list_field(tools_raw) if tools_raw else []
87
+
88
+ triggers_raw = fields.get("triggers", "")
89
+ triggers = _parse_list_field(triggers_raw) if triggers_raw else [f"/{name}"]
90
+
91
+ arguments_raw = fields.get("arguments", "")
92
+ arguments = _parse_list_field(arguments_raw) if arguments_raw else []
93
+
94
+ user_invocable_raw = fields.get("user-invocable", "true")
95
+ user_invocable = user_invocable_raw.lower() not in ("false", "0", "no")
96
+
97
+ context = fields.get("context", "inline").strip().lower()
98
+ if context not in ("inline", "fork"):
99
+ context = "inline"
100
+
101
+ return SkillDef(
102
+ name=name,
103
+ description=fields.get("description", ""),
104
+ triggers=triggers,
105
+ tools=tools,
106
+ prompt=prompt,
107
+ file_path=str(path),
108
+ when_to_use=fields.get("when_to_use", ""),
109
+ argument_hint=fields.get("argument-hint", ""),
110
+ arguments=arguments,
111
+ model=fields.get("model", ""),
112
+ user_invocable=user_invocable,
113
+ context=context,
114
+ source=source,
115
+ )
116
+
117
+
118
+ # ── Registry of built-in skills (registered by builtin.py) ────────────────
119
+
120
+ _BUILTIN_SKILLS: list[SkillDef] = []
121
+
122
+
123
+ def register_builtin_skill(skill: SkillDef) -> None:
124
+ _BUILTIN_SKILLS.append(skill)
125
+
126
+
127
+ # ── Load all skills ────────────────────────────────────────────────────────
128
+
129
+ def load_skills(include_builtins: bool = True) -> list[SkillDef]:
130
+ """Return skills from disk + builtins, deduplicated (project > user > builtin)."""
131
+ seen: dict[str, SkillDef] = {}
132
+
133
+ # Builtins go in first (lowest priority)
134
+ if include_builtins:
135
+ for sk in _BUILTIN_SKILLS:
136
+ seen[sk.name] = sk
137
+
138
+ # User-level next, project-level last (highest priority)
139
+ skill_paths = _get_skill_paths()
140
+ for i, skill_dir in enumerate(reversed(skill_paths)):
141
+ src = "user" if i == 0 else "project"
142
+ if not skill_dir.is_dir():
143
+ continue
144
+ # Support both flat files and directories (new style)
145
+ for md_file in sorted(skill_dir.rglob("SKILL.md")):
146
+ skill = _parse_skill_file(md_file, source=src)
147
+ if skill:
148
+ seen[skill.name] = skill
149
+ # Legacy: flat md files
150
+ for md_file in sorted(skill_dir.glob("*.md")):
151
+ if md_file.name == "SKILL.md": continue
152
+ skill = _parse_skill_file(md_file, source=src)
153
+ if skill:
154
+ seen[skill.name] = skill
155
+
156
+ return list(seen.values())
157
+
158
+
159
+ def find_skill(query: str) -> Optional[SkillDef]:
160
+ """Find a skill whose trigger matches the first word (or whole string) of query."""
161
+ query = query.strip()
162
+ if not query:
163
+ return None
164
+
165
+ first_word = query.split()[0]
166
+ for skill in load_skills():
167
+ for trigger in skill.triggers:
168
+ if first_word == trigger:
169
+ return skill
170
+ if trigger.startswith(first_word + " "):
171
+ return skill
172
+ return None
173
+
174
+
175
+ # ── Argument substitution ─────────────────────────────────────────────────
176
+
177
+ _PLACEHOLDER_RE = re.compile(r"^[A-Z][A-Z0-9_]*$")
178
+
179
+
180
+ def substitute_arguments(prompt: str, args: str, arg_names: list[str]) -> str:
181
+ """Replace $ARGUMENTS (whole args string) and $ARG_NAME placeholders.
182
+
183
+ Named args are positional: first word → first name, etc.
184
+ Values are substituted literally; placeholder *names* are validated to
185
+ avoid pathological replace() chains, but values are NOT shell-escaped —
186
+ callers using the result in shells must quote it themselves.
187
+ """
188
+ result = prompt.replace("$ARGUMENTS", args)
189
+
190
+ arg_values = args.split()
191
+ for i, arg_name in enumerate(arg_names):
192
+ upper = arg_name.upper()
193
+ if not _PLACEHOLDER_RE.match(upper):
194
+ continue # skip suspicious placeholder names
195
+ placeholder = f"${upper}"
196
+ value = arg_values[i] if i < len(arg_values) else ""
197
+ result = result.replace(placeholder, value)
198
+
199
+ return result