deepmodel-dmx 0.1.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 (40) hide show
  1. deepmodel_dmx-0.1.0.dist-info/METADATA +219 -0
  2. deepmodel_dmx-0.1.0.dist-info/RECORD +40 -0
  3. deepmodel_dmx-0.1.0.dist-info/WHEEL +4 -0
  4. deepmodel_dmx-0.1.0.dist-info/entry_points.txt +2 -0
  5. deepmodel_dmx-0.1.0.dist-info/licenses/LICENSE +38 -0
  6. dmx/__init__.py +24 -0
  7. dmx/_workflow_version.py +10 -0
  8. dmx/catalog.py +260 -0
  9. dmx/cli.py +196 -0
  10. dmx/exceptions.py +55 -0
  11. dmx/http_auth.py +50 -0
  12. dmx/ide/__init__.py +12 -0
  13. dmx/ide/detect.py +86 -0
  14. dmx/ide/emitters.py +264 -0
  15. dmx/rules/system-prompt.md +116 -0
  16. dmx/server.py +244 -0
  17. dmx/skills/specialist/dmx-docs.md +132 -0
  18. dmx/skills/specialist/dmx-review.md +101 -0
  19. dmx/skills/specialist/dmx-secure.md +112 -0
  20. dmx/skills/specialist/dmx-test.md +102 -0
  21. dmx/skills/utility/dmx-commit.md +174 -0
  22. dmx/skills/utility/dmx-create-branch.md +216 -0
  23. dmx/skills/utility/dmx-draft-pr-description.md +176 -0
  24. dmx/skills/utility/dmx-status.md +114 -0
  25. dmx/skills/utility/dmx-sync-branch.md +96 -0
  26. dmx/skills/utility/dmx-update-memory.md +114 -0
  27. dmx/skills/workflow/0-init/dmx-init.md +346 -0
  28. dmx/skills/workflow/1-triage/dmx-create-ticket.md +268 -0
  29. dmx/skills/workflow/1-triage/dmx-derive-ticket.md +267 -0
  30. dmx/skills/workflow/1-triage/dmx-hotfix.md +122 -0
  31. dmx/skills/workflow/2-plan/dmx-plan.md +132 -0
  32. dmx/skills/workflow/3-build/dmx-implement-next-phase.md +88 -0
  33. dmx/skills/workflow/3-build/dmx-implement-next-task.md +84 -0
  34. dmx/skills/workflow/4-validate/dmx-validate.md +173 -0
  35. dmx/skills/workflow/5-ship/dmx-close-ticket.md +208 -0
  36. dmx/skills/workflow/5-ship/dmx-create-pr.md +153 -0
  37. dmx/skills/workflow/6-release/dmx-create-release.md +122 -0
  38. dmx/skills/workflow/6-release/dmx-draft-release-note.md +216 -0
  39. dmx/skills/workflow/6-release/dmx-release-merge.md +144 -0
  40. dmx/tools.py +244 -0
@@ -0,0 +1,219 @@
1
+ Metadata-Version: 2.4
2
+ Name: deepmodel-dmx
3
+ Version: 0.1.0
4
+ Summary: AI SDLC MCP server — skills and rules for any AI IDE.
5
+ License: AGPL-3.0
6
+ License-File: LICENSE
7
+ Keywords: ai,claude,cursor,mcp,sdlc
8
+ Classifier: Development Status :: 3 - Alpha
9
+ Classifier: Intended Audience :: Developers
10
+ Classifier: License :: OSI Approved :: GNU Affero General Public License v3
11
+ Classifier: Programming Language :: Python :: 3
12
+ Classifier: Programming Language :: Python :: 3.11
13
+ Classifier: Programming Language :: Python :: 3.12
14
+ Classifier: Programming Language :: Python :: 3.13
15
+ Requires-Python: >=3.11
16
+ Requires-Dist: click>=8.1
17
+ Requires-Dist: fastmcp>=2.0
18
+ Requires-Dist: python-frontmatter>=1.1
19
+ Requires-Dist: pyyaml>=6.0
20
+ Requires-Dist: starlette>=0.40
21
+ Provides-Extra: dev
22
+ Requires-Dist: httpx2>=2.2.0; extra == 'dev'
23
+ Requires-Dist: mypy>=1.10; extra == 'dev'
24
+ Requires-Dist: pre-commit>=3; extra == 'dev'
25
+ Requires-Dist: pytest-asyncio>=0.24; extra == 'dev'
26
+ Requires-Dist: pytest-cov>=5; extra == 'dev'
27
+ Requires-Dist: pytest>=8; extra == 'dev'
28
+ Requires-Dist: ruff>=0.4; extra == 'dev'
29
+ Requires-Dist: syrupy>=4; extra == 'dev'
30
+ Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
31
+ Provides-Extra: watch
32
+ Requires-Dist: watchfiles>=0.21; extra == 'watch'
33
+ Description-Content-Type: text/markdown
34
+
35
+ # dmx
36
+
37
+ [![PyPI](https://img.shields.io/pypi/v/deepmodel-dmx)](https://pypi.org/project/deepmodel-dmx/)
38
+ [![Test](https://github.com/deepmodel-ai/dmx/actions/workflows/test.yml/badge.svg)](https://github.com/deepmodel-ai/dmx/actions/workflows/test.yml)
39
+ [![License: AGPL-3.0](https://img.shields.io/badge/license-AGPL--3.0-blue.svg)](LICENSE)
40
+
41
+ The official implementation of the [AI SDLC](https://github.com/deepmodel-ai/ai-sdlc) — delivered as an MCP server for any AI IDE.
42
+
43
+ `dmx` turns the AI SDLC framework into slash commands and always-apply engineering rules. Connect it to Cursor, Claude Code, GitHub Copilot, Antigravity, or any MCP-compatible IDE and get a structured, phase-gated AI engineering workflow out of the box.
44
+
45
+ ---
46
+
47
+ ## The workflow
48
+
49
+ The [AI SDLC](https://github.com/deepmodel-ai/ai-sdlc) structures development around five phases with explicit developer control points. AI executes within each phase and stops. The developer drives every transition forward.
50
+
51
+ ![AI SDLC Phase Arc](https://raw.githubusercontent.com/deepmodel-ai/ai-sdlc/main/assets/phase-arc.drawio.svg)
52
+
53
+ | Phase | What happens | dmx command |
54
+ |---|---|---|
55
+ | **Specify** | AI drafts a structured spec, surfaces ambiguity, asks Q&A. Developer answers. | `/dmx/create-ticket` |
56
+ | **Plan** | AI generates a phased task list from the answered spec. Developer reviews. | `/dmx/plan` |
57
+ | **Build** | AI implements one phase, stops, reports. Developer reviews and commits. Repeats. | `/dmx/implement-next-phase` |
58
+ | **Validate** | Automated quality gate checks spec, security, coverage. AI drafts the PR body. | `/dmx/validate` · `/dmx/create-pr` |
59
+ | **Ship** | Developer merges. AI tags the release and publishes. Developer confirms. | `/dmx/create-release` |
60
+
61
+ > The model does not merge. It does not advance the workflow. It does not decide when the work is done.
62
+
63
+ ---
64
+
65
+ ## Quick start
66
+
67
+ Add to your IDE's MCP config and restart:
68
+
69
+ **Cursor** — `~/.cursor/mcp.json`
70
+
71
+ ```json
72
+ {
73
+ "mcpServers": {
74
+ "dmx": {
75
+ "command": "uvx",
76
+ "args": ["--from", "deepmodel-dmx", "dmx", "serve"]
77
+ }
78
+ }
79
+ }
80
+ ```
81
+
82
+ **Claude Code** — `~/.claude/claude_desktop_config.json`
83
+
84
+ ```json
85
+ {
86
+ "mcpServers": {
87
+ "dmx": {
88
+ "command": "uvx",
89
+ "args": ["--from", "deepmodel-dmx", "dmx", "serve"]
90
+ }
91
+ }
92
+ }
93
+ ```
94
+
95
+ **Antigravity** — `~/.gemini/config/mcp_config.json`
96
+
97
+ ```json
98
+ {
99
+ "mcpServers": {
100
+ "dmx": {
101
+ "command": "uvx",
102
+ "args": ["--from", "deepmodel-dmx", "dmx", "serve"]
103
+ }
104
+ }
105
+ }
106
+ ```
107
+
108
+ **GitHub Copilot** — `.vscode/settings.json` (workspace) or user settings
109
+
110
+ ```json
111
+ {
112
+ "mcp": {
113
+ "servers": {
114
+ "dmx": {
115
+ "command": "uvx",
116
+ "args": ["--from", "deepmodel-dmx", "dmx", "serve"]
117
+ }
118
+ }
119
+ }
120
+ }
121
+ ```
122
+
123
+ Then run `/dmx-init` in your IDE to write always-apply rules and scaffold the `.dmx/` memory bank.
124
+
125
+ ---
126
+
127
+ ## `/dmx-init` walkthrough
128
+
129
+ `/dmx-init` is the one-time project setup skill. It:
130
+
131
+ 1. Detects your workflow mode (feature branches vs. trunk) and ticketing system
132
+ 2. Auto-detects your GitHub remote and tech stack
133
+ 3. Writes `.dmx/config.md` with project context
134
+ 4. Scaffolds the `.dmx/` memory bank (architecture, decisions, style guide, etc.)
135
+ 5. Calls `detect_invoking_ide` and `setup_ide_rules` to write IDE-specific rule files
136
+ 6. Writes the always-apply persona rule so every future chat starts with the right context
137
+
138
+ Safe to re-run — updates config without overwriting memory bank files that already have content.
139
+
140
+ ---
141
+
142
+ ## Skill catalog
143
+
144
+ All `/dmx-*` commands are MCP prompts served live — no file writes, no installation beyond the MCP config.
145
+
146
+ ### Workflow
147
+
148
+ | Skill | What it does |
149
+ |---|---|
150
+ | `/dmx/init` | One-time project setup: rules, memory bank, IDE config |
151
+ | `/dmx/create-ticket` | Idea → ticket → branch → spec in one command |
152
+ | `/dmx/derive-ticket` | Uncommitted changes → ticket → branch → derived spec |
153
+ | `/dmx/plan` | Answered spec → phased `tasks.md` |
154
+ | `/dmx/implement-next-phase` | Execute the next phase in `tasks.md`, stop |
155
+ | `/dmx/implement-next-task` | Execute the next single task, stop |
156
+ | `/dmx/validate` | Pre-PR quality gate: ticket, code, security |
157
+ | `/dmx/create-branch` | Create a properly named branch, scaffold spec |
158
+ | `/dmx/commit` | Conventional commit from staged diff |
159
+ | `/dmx/create-pr` | Open PR with correct title + description |
160
+ | `/dmx/draft-pr-description` | Generate PR body without opening the PR |
161
+ | `/dmx/close-ticket` | Post-merge: close ticket, delete branch, archive |
162
+
163
+ ### Release
164
+
165
+ | Skill | What it does |
166
+ |---|---|
167
+ | `/dmx/hotfix` | Create hotfix branch from master |
168
+ | `/dmx/draft-release-note` | Generate release notes from merged PRs |
169
+ | `/dmx/release-merge` | Open staging → master release gate PR |
170
+ | `/dmx/create-release` | Tag and publish GitHub release |
171
+
172
+ ### Utilities
173
+
174
+ | Skill | What it does |
175
+ |---|---|
176
+ | `/dmx/status` | Snapshot of in-progress tickets and open PRs |
177
+ | `/dmx/sync-branch` | Rebase/merge base branch onto current branch |
178
+ | `/dmx/update-memory` | Sync ticket learnings into memory bank |
179
+ | `/dmx/review` | Code review: clarity, correctness, maintainability |
180
+ | `/dmx/test` | Write tests that enable change |
181
+ | `/dmx/docs` | Write clear, human-first documentation |
182
+ | `/dmx/secure` | Security analysis — thinks like an attacker |
183
+
184
+ ---
185
+
186
+ ## CLI
187
+
188
+ ```
189
+ dmx serve # stdio transport (default, for IDE MCP config)
190
+ dmx serve --http # SSE transport on :8080 (team server / Docker)
191
+ dmx serve --http --port 9000
192
+ dmx serve --watch # hot-reload on file change (requires watchfiles extra)
193
+ dmx list-skills # print all loaded skills
194
+ dmx version
195
+ ```
196
+
197
+ **Environment variables**
198
+
199
+ | Variable | Purpose | Default |
200
+ |---|---|---|
201
+ | `PORT` | HTTP port | `8080` |
202
+ | `DMX_SKILLS_DIR` | Override bundled skills directory | bundled |
203
+ | `DMX_RULES_DIR` | Override bundled rules directory | bundled |
204
+ | `DMX_IDE` | Force IDE detection (`cursor`, `claude`, `copilot`, `antigravity`, `agents`) | auto-detect |
205
+ | `MCP_API_KEY` | Bearer token for HTTP auth | — |
206
+ | `REQUIRE_API_KEY` | Enable HTTP bearer auth (`true`/`false`) | `false` |
207
+
208
+ ---
209
+
210
+ ## Requirements
211
+
212
+ - Python 3.11+
213
+ - `uv` (recommended) or any package manager that supports `uvx`
214
+
215
+ ---
216
+
217
+ ## License
218
+
219
+ [AGPL-3.0](LICENSE) — free for open-source use. Commercial licensing available from [Deepmodel](https://deepmodel.ai).
@@ -0,0 +1,40 @@
1
+ dmx/__init__.py,sha256=FKvXiNPIb80EWvfb4tDXc7EF0Miz4_oSDT8ir09hNTY,589
2
+ dmx/_workflow_version.py,sha256=malbsQczStdMzjvVKYkOKic9SYuM6M_CUtzcrA3NFOQ,390
3
+ dmx/catalog.py,sha256=j_LCH5joJjmt-qSE9d1c9Y7GW-JfuUJCZ1cNmoYIKx4,8832
4
+ dmx/cli.py,sha256=QcLTVOByBkBmCPL_oiM0_JJVunsMoPafJUYMoM2xNec,5891
5
+ dmx/exceptions.py,sha256=J6V73mXVLC9_OIogZBhL4DfPGPGSXInHBbPAsspABBI,1293
6
+ dmx/http_auth.py,sha256=dxZhNGC4i76O_iA7w1Ru6lGvCCyxJsn9HD-cAmsjENo,1644
7
+ dmx/server.py,sha256=9_Mfj7qVa2vNnHy6kWru6HB1m7XgzMdt65m4Bxh_g9w,8752
8
+ dmx/tools.py,sha256=w3oR4oQdnkUfqN4N4cGSiuiGpQS5Om5QHaUS0Jo78Nc,9061
9
+ dmx/ide/__init__.py,sha256=XIKMwOowWRpgAnrilOyI48s8dyIVgA5Z6-fADvM9fuw,291
10
+ dmx/ide/detect.py,sha256=IjkXUBPQHlMwUi2FJtDZUQJTopRUCAgcNln-megwW78,3376
11
+ dmx/ide/emitters.py,sha256=t66e9aj5pc6zIHGuVXt2plPnsShVtWe8OvKG9wls3Og,8508
12
+ dmx/rules/system-prompt.md,sha256=MSojHRNpPcdqau3LKrA6BFEvXRk2PqCx-heogzZNaLg,5819
13
+ dmx/skills/specialist/dmx-docs.md,sha256=TcbH6uDIVlEM_LImYmI_PZAJuQGL4Z-XNBxCMFRy_Tc,4935
14
+ dmx/skills/specialist/dmx-review.md,sha256=6gSg9YFIinbCHzPsOQS3cezLPTwhV93MJhxvSgf1-5U,3496
15
+ dmx/skills/specialist/dmx-secure.md,sha256=UQpp-ZZ2ogBOsnSRD9X9cXoBKI9spQueLotlxyAYrgY,4055
16
+ dmx/skills/specialist/dmx-test.md,sha256=jZF52vzMHLgHkI3onJSiTrOmD005rkJTxnc5MZ5kbco,4104
17
+ dmx/skills/utility/dmx-commit.md,sha256=9_RntB1eXTyiHFM5HjNAKKY5z9rlQ3NhQo7yfpB1vqc,6067
18
+ dmx/skills/utility/dmx-create-branch.md,sha256=g9umv8jXxL5gF6tOpMtWSJAeK1uzns_SHbb6bPp8m1I,6926
19
+ dmx/skills/utility/dmx-draft-pr-description.md,sha256=1_AoJv1TVxdQlwh56lVSuyy_LDfZFv5-F_1A4B_xfwM,6714
20
+ dmx/skills/utility/dmx-status.md,sha256=U64WfRb1bigzMVroPx4FoKYEro8Q2G7vLdjbxZdvjB4,3315
21
+ dmx/skills/utility/dmx-sync-branch.md,sha256=C0_nIIdKloDMeqQZf01fr75ZZXqlFd63s8xcUN90dZE,2582
22
+ dmx/skills/utility/dmx-update-memory.md,sha256=DirSebAuRO3q7kUGrwOig6XlBv3oW6gK7TCxjBUfQcc,4089
23
+ dmx/skills/workflow/0-init/dmx-init.md,sha256=PMeHwI2z7y67i_8PnsOXbQwK_3A-oj0bu_e-EnTynWc,11207
24
+ dmx/skills/workflow/1-triage/dmx-create-ticket.md,sha256=z6h-haRgG0Bd0YmpThORQhUgMLlCjYadRazJef2epSk,9118
25
+ dmx/skills/workflow/1-triage/dmx-derive-ticket.md,sha256=DjD9SDqtW0TFwoTTW-YIgh7V9BzqgCkOqOGZoJ-NVGs,8383
26
+ dmx/skills/workflow/1-triage/dmx-hotfix.md,sha256=e0DHSiwhVBwd71O4Rzg6eRhnXA9xNZUAkw7JFz19DYI,4184
27
+ dmx/skills/workflow/2-plan/dmx-plan.md,sha256=95Tr_iv2TTgJ4CdWcYbHsf2Z1-STeWXowMCi7Qvfafw,4875
28
+ dmx/skills/workflow/3-build/dmx-implement-next-phase.md,sha256=Dl0uiMrCvbk-uH6u1KH6SOQS3zeKZfrzLZJcF-hohtQ,3585
29
+ dmx/skills/workflow/3-build/dmx-implement-next-task.md,sha256=eqo8cV8gLSAPyulURBBM7erAHH3_gane2Tf3vJ4GTkE,3125
30
+ dmx/skills/workflow/4-validate/dmx-validate.md,sha256=4yeTbO2SRAD5Q4koHC0d8JlnVmkh5Rhe1vTPvkVIAFc,6088
31
+ dmx/skills/workflow/5-ship/dmx-close-ticket.md,sha256=E5vKt6Bun4hopg1dnGi1o7oO0iasuD7bY22XAUryEsc,6536
32
+ dmx/skills/workflow/5-ship/dmx-create-pr.md,sha256=ClWDcgq7rVNLX_DqH0t2h4SqUXCgMxbdZcmx0ESJ9fM,4885
33
+ dmx/skills/workflow/6-release/dmx-create-release.md,sha256=ufLqxoKmWObeic73ufzpeRfToTDkhKB6LxzJpLe7_kI,4021
34
+ dmx/skills/workflow/6-release/dmx-draft-release-note.md,sha256=QGiEEaerOnG9bTpmPWr2QY29gdUqPfJMjr4AdlSDUtY,6102
35
+ dmx/skills/workflow/6-release/dmx-release-merge.md,sha256=qGhrkBsL0j8Ig_qKoomWlMLwRB563Rur15Q7p2ZI4aM,5152
36
+ deepmodel_dmx-0.1.0.dist-info/METADATA,sha256=Updtkyo7OfAsdgrZvxPnvQ6-wULNr5uLIJ8yjWzVMhk,7594
37
+ deepmodel_dmx-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
38
+ deepmodel_dmx-0.1.0.dist-info/entry_points.txt,sha256=zC3IDbfwwSPWurk6bKz1oF8sAvGZ0U8JohQ8NZIDYqs,37
39
+ deepmodel_dmx-0.1.0.dist-info/licenses/LICENSE,sha256=g_rT-ZAvrkFf4v_I3nAwt_RygoWN4M2nZ08l7405lvQ,1809
40
+ deepmodel_dmx-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.30.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dmx = dmx.cli:main
@@ -0,0 +1,38 @@
1
+ GNU AFFERO GENERAL PUBLIC LICENSE
2
+ Version 3, 19 November 2007
3
+
4
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
5
+ Everyone is permitted to copy and distribute verbatim copies
6
+ of this license document, but changing it is not allowed.
7
+
8
+ Preamble
9
+
10
+ The GNU Affero General Public License is a free, copyleft license for
11
+ software and other kinds of works, specifically designed to ensure
12
+ cooperation with the community in the case of network server software.
13
+
14
+ The licenses for most software and other practical works are designed
15
+ to take away your freedom to share and change the works. By contrast,
16
+ our General Public Licenses are intended to guarantee your freedom to
17
+ share and change all versions of a program--to make sure it remains free
18
+ software for all its users.
19
+
20
+ When we speak of free software, we are referring to freedom, not
21
+ price. Our General Public Licenses are designed to make sure that you
22
+ have the freedom to distribute copies of free software (and charge for
23
+ them if you wish), that you receive source code or can modify it, that
24
+ you know you can do these rights, and that you have the freedom to use,
25
+ share, and change the software.
26
+
27
+ Specifically, the GNU Affero General Public License is designed to
28
+ ensure that anyone running a modified version of the software over a
29
+ network can receive the source code for that version.
30
+
31
+ For the full license text, see: https://www.gnu.org/licenses/agpl-3.0.txt
32
+
33
+ Copyright (C) 2024 Deepmodel Inc.
34
+ All Rights Reserved under AGPL-3.0.
35
+
36
+ Additional permissions: Deepmodel Inc. retains the right to release
37
+ the software under different license terms (dual licensing). See
38
+ CLA.md for contributor terms.
dmx/__init__.py ADDED
@@ -0,0 +1,24 @@
1
+ """dmx — AI SDLC MCP server."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dmx.catalog import RuleDefinition, SkillArgument, SkillDefinition
6
+ from dmx.exceptions import DmxError, EmitterError, IdeDetectionError, RuleLoadError, SkillLoadError
7
+ from dmx.ide.emitters import IdeRuleFile
8
+ from dmx.server import create_app
9
+
10
+ __all__ = [
11
+ # Exceptions
12
+ "DmxError",
13
+ "EmitterError",
14
+ "IdeDetectionError",
15
+ "RuleLoadError",
16
+ "SkillLoadError",
17
+ # Data classes
18
+ "IdeRuleFile",
19
+ "RuleDefinition",
20
+ "SkillArgument",
21
+ "SkillDefinition",
22
+ # Factory
23
+ "create_app",
24
+ ]
@@ -0,0 +1,10 @@
1
+ """Workflow version — independent of the package version.
2
+
3
+ Bump this constant when skills or rules change in a way that requires
4
+ developers to re-run ``/dmx/init`` to get current behaviour.
5
+
6
+ Do NOT bump for: bug fixes, new IDE emitters, CLI changes, dependency updates.
7
+ DO bump for: new or removed skills, system-prompt rewrites, rule restructuring.
8
+ """
9
+
10
+ WORKFLOW_VERSION = "workflow-v1"
dmx/catalog.py ADDED
@@ -0,0 +1,260 @@
1
+ """Skill and rule catalog: load, parse, and template substitution."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ import re
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path # noqa: TCH003 — used at runtime in rglob/stat calls
9
+ from typing import Any, cast
10
+
11
+ import frontmatter
12
+
13
+ from dmx.exceptions import RuleLoadError, SkillLoadError
14
+
15
+ __all__ = [
16
+ "ARG_NAME_RE",
17
+ "SLUG_RE",
18
+ "SkillArgument",
19
+ "SkillDefinition",
20
+ "RuleDefinition",
21
+ "load_skills",
22
+ "load_rules",
23
+ "substitute_args",
24
+ ]
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ SLUG_RE = re.compile(r"^[a-z0-9_-]+$")
29
+ # Argument names must be valid Python identifiers (lowercase, underscores only).
30
+ # Hyphens are intentionally excluded: argument names are template placeholders
31
+ # and Python parameter names — hyphens are not valid in either context.
32
+ ARG_NAME_RE = re.compile(r"^[a-z][a-z0-9_]*$")
33
+ MAX_SKILL_BYTES = 256 * 1024 # 256 KiB
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class SkillArgument:
38
+ """A single argument accepted by a skill.
39
+
40
+ Attributes:
41
+ name: Argument name used in ``{{name}}`` template placeholders.
42
+ description: Human-readable description shown in IDE command palette.
43
+ required: Whether the argument must be provided at invocation time.
44
+ """
45
+
46
+ name: str
47
+ description: str = ""
48
+ required: bool = False
49
+
50
+
51
+ @dataclass(frozen=True)
52
+ class SkillDefinition:
53
+ """A fully parsed skill loaded from a Markdown file.
54
+
55
+ Attributes:
56
+ name: Slug from frontmatter ``name`` field; falls back to filename stem.
57
+ title: Display name shown in IDE command palette.
58
+ description: Short description shown in IDE command palette.
59
+ arguments: Tuple of accepted arguments.
60
+ body: Raw Markdown body with ``{{arg}}`` placeholders.
61
+ """
62
+
63
+ name: str
64
+ title: str
65
+ description: str
66
+ arguments: tuple[SkillArgument, ...] = field(default_factory=tuple)
67
+ body: str = ""
68
+
69
+
70
+ @dataclass(frozen=True)
71
+ class RuleDefinition:
72
+ """A fully parsed rule loaded from a Markdown file.
73
+
74
+ Attributes:
75
+ name: Slug from frontmatter ``name`` field; falls back to filename stem.
76
+ title: Display name.
77
+ description: Short description shown in IDE rule picker.
78
+ always_apply: Whether the rule is always injected into context.
79
+ globs: Optional file-pattern filter (Cursor ``.mdc`` / Claude paths).
80
+ ides: Target IDEs; empty tuple means all targets.
81
+ body: Raw Markdown rule body.
82
+ """
83
+
84
+ name: str
85
+ title: str
86
+ description: str = ""
87
+ always_apply: bool = True
88
+ globs: str | None = None
89
+ ides: tuple[str, ...] = field(default_factory=tuple)
90
+ body: str = ""
91
+
92
+
93
+ def load_skills(directory: Path) -> tuple[SkillDefinition, ...]:
94
+ """Load all skill files from *directory* recursively.
95
+
96
+ Files that cannot be parsed are skipped with a warning; the rest are
97
+ returned. Files larger than ``MAX_SKILL_BYTES`` are also skipped.
98
+
99
+ Args:
100
+ directory: Root directory to scan with ``rglob("*.md")``.
101
+
102
+ Returns:
103
+ Tuple of parsed :class:`SkillDefinition` objects, sorted by name.
104
+ """
105
+ skills: list[SkillDefinition] = []
106
+ for path in directory.rglob("*.md"):
107
+ try:
108
+ skill = _parse_skill(path)
109
+ except SkillLoadError as exc:
110
+ logger.warning("skipping skill — %s", exc)
111
+ continue
112
+ if skill is not None:
113
+ skills.append(skill)
114
+ return tuple(sorted(skills, key=lambda s: s.name))
115
+
116
+
117
+ def load_rules(directory: Path) -> tuple[RuleDefinition, ...]:
118
+ """Load all rule files from *directory* recursively.
119
+
120
+ Files that cannot be parsed are skipped with a warning.
121
+
122
+ Args:
123
+ directory: Root directory to scan with ``rglob("*.md")``.
124
+
125
+ Returns:
126
+ Tuple of parsed :class:`RuleDefinition` objects, sorted by name.
127
+ """
128
+ rules: list[RuleDefinition] = []
129
+ for path in directory.rglob("*.md"):
130
+ try:
131
+ rule = _parse_rule(path)
132
+ except RuleLoadError as exc:
133
+ logger.warning("skipping rule — %s", exc)
134
+ continue
135
+ if rule is not None:
136
+ rules.append(rule)
137
+ return tuple(sorted(rules, key=lambda r: r.name))
138
+
139
+
140
+ def substitute_args(template: str, args: dict[str, str | None]) -> str:
141
+ """Substitute ``{{name}}`` placeholders in *template* with *args* values.
142
+
143
+ Unknown placeholders and those whose value is ``None`` resolve to an
144
+ empty string.
145
+
146
+ Args:
147
+ template: Markdown template containing ``{{name}}`` placeholders.
148
+ args: Mapping of argument name to value.
149
+
150
+ Returns:
151
+ Template with all placeholders resolved.
152
+ """
153
+
154
+ def _replace(match: re.Match[str]) -> str:
155
+ key = match.group(1).strip()
156
+ value = args.get(key)
157
+ return value if value is not None else ""
158
+
159
+ return re.sub(r"\{\{([^}]+)\}\}", _replace, template)
160
+
161
+
162
+ # ---------------------------------------------------------------------------
163
+ # Private helpers
164
+ # ---------------------------------------------------------------------------
165
+
166
+
167
+ def _valid_arg_name(skill_name: str, arg_name: object) -> bool:
168
+ """Return True if *arg_name* is a valid skill argument name.
169
+
170
+ Argument names must be lowercase Python identifiers (letters, digits,
171
+ underscores; must start with a letter). Hyphens are rejected: they are
172
+ not valid Python identifiers and cannot be used as template placeholders
173
+ or function parameter names without conversion gymnastics.
174
+ """
175
+ if not isinstance(arg_name, str) or not ARG_NAME_RE.match(arg_name):
176
+ logger.warning(
177
+ "skill %r: argument name %r is invalid (must match %s) — skipping argument",
178
+ skill_name,
179
+ arg_name,
180
+ ARG_NAME_RE.pattern,
181
+ )
182
+ return False
183
+ return True
184
+
185
+
186
+ def _parse_skill(path: Path) -> SkillDefinition | None:
187
+ """Parse a single skill file; return None and log a warning on failure."""
188
+ try:
189
+ if path.stat().st_size > MAX_SKILL_BYTES:
190
+ logger.warning("skill file exceeds size limit, skipping: %s", path)
191
+ return None
192
+
193
+ post = frontmatter.load(str(path))
194
+ name = str(post.metadata.get("name", path.stem))
195
+
196
+ if not SLUG_RE.match(name):
197
+ logger.warning("invalid skill name %r, skipping: %s", name, path)
198
+ return None
199
+
200
+ raw_args_value = post.metadata.get("arguments", [])
201
+ if not isinstance(raw_args_value, list):
202
+ logger.warning(
203
+ "skill %r: 'arguments' must be a list, got %s — ignoring arguments",
204
+ name,
205
+ type(raw_args_value).__name__,
206
+ )
207
+ raw_args_value = []
208
+ raw_args: list[dict[str, Any]] = cast("list[dict[str, Any]]", raw_args_value)
209
+ arguments = tuple(
210
+ SkillArgument(
211
+ name=str(arg["name"]),
212
+ description=str(arg.get("description", "")),
213
+ required=bool(arg.get("required", False)),
214
+ )
215
+ for arg in raw_args
216
+ if isinstance(arg, dict) and "name" in arg and _valid_arg_name(name, arg["name"])
217
+ )
218
+
219
+ return SkillDefinition(
220
+ name=name,
221
+ title=str(post.metadata.get("title", name)),
222
+ description=str(post.metadata.get("description", "")),
223
+ arguments=arguments,
224
+ body=post.content,
225
+ )
226
+ except Exception as exc: # noqa: BLE001
227
+ raise SkillLoadError(f"failed to load skill at {path}: {exc}", path=str(path)) from exc
228
+
229
+
230
+ def _parse_rule(path: Path) -> RuleDefinition | None:
231
+ """Parse a single rule file; return None and log a warning on failure."""
232
+ try:
233
+ post = frontmatter.load(str(path))
234
+ name = str(post.metadata.get("name", path.stem))
235
+
236
+ if not SLUG_RE.match(name):
237
+ logger.warning("invalid rule name %r, skipping: %s", name, path)
238
+ return None
239
+
240
+ raw_ides_value = post.metadata.get("ides", [])
241
+ # Normalize: YAML scalar string → list; e.g. `ides: cursor` → ["cursor"]
242
+ if isinstance(raw_ides_value, str):
243
+ raw_ides_value = [raw_ides_value]
244
+ raw_ides: list[str] = cast("list[str]", raw_ides_value or [])
245
+ ides = tuple(raw_ides)
246
+
247
+ globs_raw = post.metadata.get("globs")
248
+ globs: str | None = str(globs_raw) if globs_raw is not None else None
249
+
250
+ return RuleDefinition(
251
+ name=name,
252
+ title=str(post.metadata.get("title", name)),
253
+ description=str(post.metadata.get("description", "")),
254
+ always_apply=bool(post.metadata.get("alwaysApply", True)),
255
+ globs=globs,
256
+ ides=ides,
257
+ body=post.content,
258
+ )
259
+ except Exception as exc: # noqa: BLE001
260
+ raise RuleLoadError(f"failed to load rule at {path}: {exc}", path=str(path)) from exc