@michaelbel/cuckcoder-mcp 1.6.11
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.
- package/assets/rules/architecture.md +15 -0
- package/assets/rules/bottom-sheet.md +20 -0
- package/assets/rules/compose-color.md +22 -0
- package/assets/rules/compose-constraintlayout.md +18 -0
- package/assets/rules/compose-screen.md +33 -0
- package/assets/rules/compose-spacing.md +39 -0
- package/assets/rules/compose.md +54 -0
- package/assets/rules/dialog.md +33 -0
- package/assets/rules/domain.md +36 -0
- package/assets/rules/filesystem.md +3 -0
- package/assets/rules/git.md +11 -0
- package/assets/rules/github-readme.md +97 -0
- package/assets/rules/github-repo.md +65 -0
- package/assets/rules/kmp.md +14 -0
- package/assets/rules/kotlin.md +48 -0
- package/assets/rules/lazylist.md +36 -0
- package/assets/rules/mvi-error-handling.md +19 -0
- package/assets/rules/mvi-state.md +31 -0
- package/assets/rules/mvi.md +39 -0
- package/assets/rules/navigation.md +20 -0
- package/assets/rules/network.md +36 -0
- package/assets/rules/preview.md +31 -0
- package/assets/rules/realtime.md +39 -0
- package/assets/rules/resource.md +18 -0
- package/assets/rules/room.md +33 -0
- package/assets/rules/scaffold.md +25 -0
- package/assets/rules/shimmer.md +34 -0
- package/assets/rules/textfield.md +31 -0
- package/assets/rules/typography.md +32 -0
- package/assets/rules/usecase.md +60 -0
- package/assets/rules/workflow.md +17 -0
- package/assets/rules/workmanager.md +44 -0
- package/assets/skills/create-data-layer/SKILL.md +60 -0
- package/assets/skills/create-datastore-preference/SKILL.md +80 -0
- package/assets/skills/create-domain-mapper/SKILL.md +30 -0
- package/assets/skills/create-feature-alert-dialog/SKILL.md +549 -0
- package/assets/skills/create-feature-bottom-sheet/SKILL.md +300 -0
- package/assets/skills/create-feature-scaffold-screen/SKILL.md +74 -0
- package/assets/skills/create-feature-scaffold-screen/references/form-screen.md +20 -0
- package/assets/skills/create-feature-scaffold-screen/references/network-only-screen.md +20 -0
- package/assets/skills/create-feature-scaffold-screen/references/paginated-screen.md +29 -0
- package/assets/skills/create-feature-scaffold-screen/references/room-backed-screen.md +23 -0
- package/assets/skills/create-feature-scaffold-screen/references/static-screen.md +13 -0
- package/assets/skills/create-guide/SKILL.md +169 -0
- package/assets/skills/create-guide/references/android-project.md +120 -0
- package/assets/skills/create-guide/references/androidx.md +49 -0
- package/assets/skills/create-guide/references/existing-guides.yaml +60 -0
- package/assets/skills/create-guide/references/github.md +71 -0
- package/assets/skills/create-guide/references/guide-defaults.yaml +51 -0
- package/assets/skills/create-guide/references/notion.md +121 -0
- package/assets/skills/create-guide/references/output-contract.md +79 -0
- package/assets/skills/create-guide/references/research.md +64 -0
- package/assets/skills/create-guide/references/validation.md +59 -0
- package/assets/skills/create-guide/scripts/validate_guide.py +167 -0
- package/assets/skills/create-guide/templates/guide-manifest.yaml +67 -0
- package/assets/skills/create-guide/templates/implementation-plan.md +30 -0
- package/assets/skills/create-guide/templates/notion-page.md +46 -0
- package/assets/skills/create-guide/templates/repository/AGENTS.md +7 -0
- package/assets/skills/create-guide/templates/repository/README.md +32 -0
- package/assets/skills/create-guide/templates/repository/SOURCES.md +32 -0
- package/assets/skills/create-guide/templates/validation-report.md +30 -0
- package/assets/skills/create-guide/tests/test_validate_guide.py +97 -0
- package/assets/skills/create-ktor-endpoint/SKILL.md +33 -0
- package/assets/skills/create-notification-flow/SKILL.md +103 -0
- package/assets/skills/create-offline-outbox/SKILL.md +110 -0
- package/assets/skills/create-paging-flow/SKILL.md +96 -0
- package/assets/skills/create-project-from-template/SKILL.md +91 -0
- package/assets/skills/create-project-from-template/assets/icons/android.svg +12 -0
- package/assets/skills/create-project-from-template/assets/icons/compose.svg +34 -0
- package/assets/skills/create-project-from-template/assets/icons/jetpack.svg +10 -0
- package/assets/skills/create-project-from-template/references/myapplication-checklist.md +92 -0
- package/assets/skills/create-room-storage/SKILL.md +39 -0
- package/assets/skills/create-shared-component/SKILL.md +160 -0
- package/assets/skills/create-signalr-channel/SKILL.md +39 -0
- package/assets/skills/create-usecase/SKILL.md +142 -0
- package/assets/skills/create-workmanager-task/SKILL.md +41 -0
- package/assets/skills/github-repo-settings/SKILL.md +152 -0
- package/assets/skills/interview-me/SKILL.md +150 -0
- package/dist/errors.js +49 -0
- package/dist/frontmatter.js +44 -0
- package/dist/github.js +23 -0
- package/dist/index.js +6 -0
- package/dist/server.js +131 -0
- package/dist/source/bundled.js +78 -0
- package/dist/source/cache.js +61 -0
- package/dist/source/github-source.js +68 -0
- package/dist/source/github.js +185 -0
- package/dist/source/index.js +36 -0
- package/dist/source/types.js +1 -0
- package/dist/validation.js +33 -0
- package/dist/version.js +26 -0
- package/package.json +38 -0
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: interview-me
|
|
3
|
+
description: >-
|
|
4
|
+
Use when требования расплывчаты, противоречат друг другу или короче, чем заслуживает задача —
|
|
5
|
+
перед тем как ставить acceptance criteria и делегировать реализацию. Триггерные фразы: "добавь
|
|
6
|
+
оффлайн", "сделай синхронизацию", "разберись сам", одна-две фразы на фичу, которая явно больше
|
|
7
|
+
одной фразы, противоречие между тем, что просит пользователь, и тем, что уже делает код.
|
|
8
|
+
Структурированный опрос малыми пачками вопросов вместо накопления предположений: поднимает
|
|
9
|
+
уверенность в требованиях до ~95%, прежде чем main-сессия сформулирует задачу профильному агенту
|
|
10
|
+
(`kotlin-engineer`, `compose-builder`, `swift-engineer`, `swiftui-builder`, `guide-android-builder`
|
|
11
|
+
и т.д.) или бизнес-аналитику `business-analyst`. Не используй, если задача маленькая и
|
|
12
|
+
однозначная (переименование, точечный багфикс с понятной причиной) или acceptance criteria уже
|
|
13
|
+
явно заданы пользователем/issue — в этом случае сразу делегируй по `rules/workflow.md`.
|
|
14
|
+
metadata:
|
|
15
|
+
author: michaelbel
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
# Интервью с пользователем
|
|
19
|
+
|
|
20
|
+
## Обзор
|
|
21
|
+
|
|
22
|
+
Самый дешёвый баг — тот, что не написан, потому что нужный вопрос задали до реализации, а не после
|
|
23
|
+
код-ревью. Этот skill заменяет накопление предположений структурированным интервью: задавай
|
|
24
|
+
целевые вопросы малыми пачками, скармливай каждый ответ следующему раунду и останавливайся только
|
|
25
|
+
тогда, когда acceptance criteria можно написать не выдумывая. Он выносит разговор, который иначе
|
|
26
|
+
случится как rework после ревью, в начало — до того как задача уйдёт профильному агенту.
|
|
27
|
+
|
|
28
|
+
## Когда использовать
|
|
29
|
+
|
|
30
|
+
- Запрос на одну-две фразы описывает то, что явно больше одной-двух фраз ("добавь оффлайн-режим").
|
|
31
|
+
- Требования противоречат друг другу или текущему поведению приложения/бэкенда.
|
|
32
|
+
- Возможны несколько трактовок, и ошибиться в выборе дорого (миграция схемы, публичный контракт,
|
|
33
|
+
необратимое решение — см. также `doubt-driven-development`, если он у вас появится, либо сразу
|
|
34
|
+
`documentation-and-adrs`-практику для фиксации решения).
|
|
35
|
+
- Перед тем как отдать задачу `business-analyst` на проверку acceptance criteria, или профильному
|
|
36
|
+
инженерному агенту на реализацию.
|
|
37
|
+
- Пользователь говорит в духе "ты понимаешь, о чём я" — не понимаешь.
|
|
38
|
+
|
|
39
|
+
**Не используй, если:** задача маленькая и однозначная (rename, точечный багфикс с понятной
|
|
40
|
+
причиной), или acceptance criteria уже заданы явно пользователем/issue/спекой — тогда сразу
|
|
41
|
+
делегируй по `rules/workflow.md`. Интервью не должно быть предлогом не начинать работу.
|
|
42
|
+
|
|
43
|
+
## Процесс
|
|
44
|
+
|
|
45
|
+
### Шаг 1. Зафиксируй, что уже понятно
|
|
46
|
+
|
|
47
|
+
Прежде чем задавать вопросы, письменно зафиксируй текущее понимание и уровень уверенности:
|
|
48
|
+
|
|
49
|
+
```markdown
|
|
50
|
+
## Понимание: оффлайн-очередь мутаций задач
|
|
51
|
+
- Пользователь видит закэшированные задачи офлайн — уверенно (сказано явно)
|
|
52
|
+
- Пользователь может СОЗДАВАТЬ задачи офлайн — предположение, не сказано
|
|
53
|
+
- Стратегия разрешения конфликтов — неизвестно
|
|
54
|
+
- Триггер синка (при открытии приложения? WorkManager?) — неизвестно
|
|
55
|
+
Уверенность: ~40%
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Строки "предположение" и "неизвестно" становятся списком вопросов. Если таких строк не возникло —
|
|
59
|
+
интервью не требовалось.
|
|
60
|
+
|
|
61
|
+
### Шаг 2. Задавай вопросы малыми целевыми пачками
|
|
62
|
+
|
|
63
|
+
3–5 вопросов за раунд, самое дорогое решение — первым. Один вопрос — одно решение, конкретные
|
|
64
|
+
варианты вместо открытых формулировок:
|
|
65
|
+
|
|
66
|
+
```markdown
|
|
67
|
+
1. Если задачу отредактировали офлайн на двух устройствах, что побеждает при синке — последняя
|
|
68
|
+
запись или конфликт показывается пользователю?
|
|
69
|
+
2. Создание задач должно работать офлайн, или в v1 офлайн — только чтение?
|
|
70
|
+
3. Синк по открытию приложения или фоновый через WorkManager (с учётом Doze/App Standby)?
|
|
71
|
+
4. Что происходит с несинканными офлайн-изменениями при логауте?
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Правила вопросов:
|
|
75
|
+
- Спрашивай про поведение, которое видит пользователь, а не про реализацию ("что побеждает?", а не
|
|
76
|
+
"OT или CRDT?").
|
|
77
|
+
- Предлагай дефолт: "Предлагаю last-write-wins для v1 — годится?" — легко подтвердить, легко
|
|
78
|
+
поправить.
|
|
79
|
+
- Поднимай платформенные реалии, которые заказчик может не знать: фоновые ограничения (Doze,
|
|
80
|
+
WorkManager retry/backoff), KMP-паритет поведения между Android и iOS (если фича общая для
|
|
81
|
+
`shared/`), реконнект realtime-канала (SignalR) после разрыва сети, требования Google
|
|
82
|
+
Play/App Store review.
|
|
83
|
+
- Никогда не переспрашивай то, что уже закрыл предыдущий ответ.
|
|
84
|
+
|
|
85
|
+
### Шаг 3. Скорми ответы обратно и итерируй
|
|
86
|
+
|
|
87
|
+
После каждого раунда обнови документ понимания — перескажи ответы своими словами, отметь закрытые
|
|
88
|
+
пункты, дай новым вопросам возникнуть из ответов:
|
|
89
|
+
|
|
90
|
+
```markdown
|
|
91
|
+
Закрыто: офлайн-чтение и создание в v1; last-write-wins; синк по открытию приложения.
|
|
92
|
+
Новый вопрос из "last-write-wins": тихая потеря данных при конфликте — ок, или логировать для
|
|
93
|
+
саппорта?
|
|
94
|
+
Уверенность: ~80%
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### Шаг 4. Останавливайся на ~95% и передавай дальше
|
|
98
|
+
|
|
99
|
+
Выходи из интервью, когда каждый acceptance criterion можно написать не выдумывая. Идеальная
|
|
100
|
+
уверенность — не цель; оставшиеся ~5% фиксируются как явные предположения:
|
|
101
|
+
|
|
102
|
+
```markdown
|
|
103
|
+
Предположения (продолжаем, пока не поправят):
|
|
104
|
+
- Конфликты достаточно редки, чтобы тихий last-write-wins был приемлем для v1.
|
|
105
|
+
```
|
|
106
|
+
|
|
107
|
+
Передай результат дальше по `rules/workflow.md`: если решение архитектурное или необратимое —
|
|
108
|
+
сначала `business-analyst` на проверку с продуктовой стороны и/или ADR по
|
|
109
|
+
`documentation-and-adrs`-практике; реализацию отдай профильному агенту (`kotlin-engineer`,
|
|
110
|
+
`compose-builder`, `swift-engineer`, `swiftui-builder`) вместе с документом понимания как частью
|
|
111
|
+
постановки задачи — не как отдельное сообщение, которое потеряется в чате. Для оффлайн-очереди
|
|
112
|
+
из примера выше следующий шаг обычно — `create-offline-outbox`.
|
|
113
|
+
|
|
114
|
+
## Типичные отговорки
|
|
115
|
+
|
|
116
|
+
| Отговорка | Почему не работает |
|
|
117
|
+
|---|---|
|
|
118
|
+
| "Сделаю разумные предположения и отмечу их" | Десять сложенных "разумных" предположений дают неразумный результат. Предположения — для последних 5%, а не для первых 50%. |
|
|
119
|
+
| "Задавать вопросы — выглядит неуверенно" | Реализация не того выглядит хуже. Хорошие вопросы демонстрируют понимание задачи. |
|
|
120
|
+
| "Пользователь занят, не буду дёргать" | Пять минут его времени сейчас дешевле недели переделки и более тяжёлого разговора потом. |
|
|
121
|
+
| "Разберусь по ходу реализации" | Структурные решения (модель офлайна, стратегия конфликтов) дёшево менять в разговоре и дорого — в коде. |
|
|
122
|
+
| "Один большой список вопросов эффективнее" | Двадцать вопросов разом просто пролистают. Маленькие пачки получают настоящие ответы, а ответы меняют следующие вопросы. |
|
|
123
|
+
| "Передам неопределённость профильному агенту, он разберётся по контексту" | Профильный агент реализует, а не уточняет продуктовые решения — по `rules/workflow.md` он получает уже поставленную задачу. Нерешённая неоднозначность в постановке становится неверной реализацией, а не вопросом. |
|
|
124
|
+
|
|
125
|
+
## Красные флаги
|
|
126
|
+
|
|
127
|
+
- Реализация начата, а документ понимания всё ещё содержит "неизвестно" по ключевому поведению.
|
|
128
|
+
- Вопросы про детали реализации, а не про видимое пользователю поведение.
|
|
129
|
+
- Задача делегирована профильному агенту с нерешённой неоднозначностью вместо того, чтобы закрыть
|
|
130
|
+
её в main-сессии до делегирования.
|
|
131
|
+
- Один и тот же вопрос задан дважды, потому что ответ не зафиксировали.
|
|
132
|
+
- Ноль зафиксированных предположений в задаче, где неоднозначность точно была (значит, их сделали
|
|
133
|
+
молча).
|
|
134
|
+
- Стена из двадцати вопросов одним сообщением.
|
|
135
|
+
- "Интервью" продолжается после того, как acceptance criteria уже можно написать — это уже
|
|
136
|
+
затягивание, а не уточнение.
|
|
137
|
+
|
|
138
|
+
## Чек-лист
|
|
139
|
+
|
|
140
|
+
- [ ] Документ понимания существует и явно делит пункты на подтверждённые / предположенные /
|
|
141
|
+
неизвестные.
|
|
142
|
+
- [ ] Каждое видимое пользователю поведение закрыто ответом или явным предположением — без тихих
|
|
143
|
+
пробелов.
|
|
144
|
+
- [ ] Оставшиеся предположения записаны и показаны пользователю, а не оставлены только в голове
|
|
145
|
+
агента.
|
|
146
|
+
- [ ] Acceptance criteria можно сформулировать, ничего не выдумывая.
|
|
147
|
+
- [ ] Платформенные ограничения стека (Doze/WorkManager, KMP-паритет Android/iOS, реконнект
|
|
148
|
+
realtime, Play/App Store review) подняты там, где релевантны.
|
|
149
|
+
- [ ] Результат передан дальше как часть постановки задачи для `business-analyst` и/или
|
|
150
|
+
профильного агента — по `rules/workflow.md`, а не потерян в чате.
|
package/dist/errors.js
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
const RETRYABLE_CODES = new Set([
|
|
2
|
+
"GITHUB_TIMEOUT",
|
|
3
|
+
"GITHUB_RATE_LIMITED",
|
|
4
|
+
"GITHUB_UNREACHABLE",
|
|
5
|
+
]);
|
|
6
|
+
/**
|
|
7
|
+
* The one shared error shape for this server. Every tool handler catches its own errors and
|
|
8
|
+
* converts them (via toToolErrorResult) into this shape rather than letting an exception escape
|
|
9
|
+
* unhandled and crash the process.
|
|
10
|
+
*/
|
|
11
|
+
export class WorkflowError extends Error {
|
|
12
|
+
code;
|
|
13
|
+
retryable;
|
|
14
|
+
details;
|
|
15
|
+
constructor(code, message, options = {}) {
|
|
16
|
+
super(message);
|
|
17
|
+
this.name = "WorkflowError";
|
|
18
|
+
this.code = code;
|
|
19
|
+
this.retryable = options.retryable ?? RETRYABLE_CODES.has(code);
|
|
20
|
+
this.details = options.details;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
export function toErrorPayload(error) {
|
|
24
|
+
if (error instanceof WorkflowError) {
|
|
25
|
+
return {
|
|
26
|
+
code: error.code,
|
|
27
|
+
message: error.message,
|
|
28
|
+
retryable: error.retryable,
|
|
29
|
+
details: error.details,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
33
|
+
return {
|
|
34
|
+
code: "INTERNAL_ERROR",
|
|
35
|
+
message,
|
|
36
|
+
retryable: false,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Converts any thrown value into an MCP tool error result. Never throws itself, so it is safe to
|
|
41
|
+
* call from a top-level catch in every tool handler.
|
|
42
|
+
*/
|
|
43
|
+
export function toToolErrorResult(error) {
|
|
44
|
+
const payload = toErrorPayload(error);
|
|
45
|
+
return {
|
|
46
|
+
content: [{ type: "text", text: JSON.stringify(payload) }],
|
|
47
|
+
isError: true,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
const FRONTMATTER_PATTERN = /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/;
|
|
2
|
+
/**
|
|
3
|
+
* Minimal YAML-frontmatter parser for our own SKILL.md files.
|
|
4
|
+
*
|
|
5
|
+
* Supports single-line `key: value` fields and a folded block scalar
|
|
6
|
+
* (`key: >-` followed by indented continuation lines, joined with single spaces), which is the
|
|
7
|
+
* only multi-line form used by skills/*\/SKILL.md descriptions. This is intentionally not a
|
|
8
|
+
* general-purpose YAML parser.
|
|
9
|
+
*/
|
|
10
|
+
export function parseFrontmatter(markdown) {
|
|
11
|
+
const match = markdown.match(FRONTMATTER_PATTERN);
|
|
12
|
+
if (!match) {
|
|
13
|
+
return { fields: {}, body: markdown };
|
|
14
|
+
}
|
|
15
|
+
const rawFrontmatter = match[1];
|
|
16
|
+
const body = match[2] ?? "";
|
|
17
|
+
const lines = rawFrontmatter.split(/\r?\n/);
|
|
18
|
+
const fields = {};
|
|
19
|
+
let index = 0;
|
|
20
|
+
while (index < lines.length) {
|
|
21
|
+
const line = lines[index];
|
|
22
|
+
const fieldMatch = line.match(/^([a-zA-Z_][a-zA-Z0-9_]*):\s*(.*)$/);
|
|
23
|
+
if (!fieldMatch) {
|
|
24
|
+
index += 1;
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const key = fieldMatch[1];
|
|
28
|
+
const rest = fieldMatch[2];
|
|
29
|
+
if (rest === ">-" || rest === "|-" || rest === ">" || rest === "|") {
|
|
30
|
+
const foldedLines = [];
|
|
31
|
+
index += 1;
|
|
32
|
+
while (index < lines.length && (lines[index].startsWith(" ") || lines[index].trim() === "")) {
|
|
33
|
+
foldedLines.push(lines[index].replace(/^ {2}/, ""));
|
|
34
|
+
index += 1;
|
|
35
|
+
}
|
|
36
|
+
const joiner = rest === "|-" || rest === "|" ? "\n" : " ";
|
|
37
|
+
fields[key] = foldedLines.join(joiner).trim();
|
|
38
|
+
continue;
|
|
39
|
+
}
|
|
40
|
+
fields[key] = rest.trim();
|
|
41
|
+
index += 1;
|
|
42
|
+
}
|
|
43
|
+
return { fields, body };
|
|
44
|
+
}
|
package/dist/github.js
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const BASE_RAW = "https://raw.githubusercontent.com";
|
|
2
|
+
const BASE_API = "https://api.github.com";
|
|
3
|
+
export async function fetchFile(owner, repo, branch, path) {
|
|
4
|
+
const url = `${BASE_RAW}/${owner}/${repo}/${branch}/${path}`;
|
|
5
|
+
const res = await fetch(url);
|
|
6
|
+
if (!res.ok) {
|
|
7
|
+
throw new Error(`Not found: ${path} (HTTP ${res.status})`);
|
|
8
|
+
}
|
|
9
|
+
return res.text();
|
|
10
|
+
}
|
|
11
|
+
export async function listTree(owner, repo, branch) {
|
|
12
|
+
const url = `${BASE_API}/repos/${owner}/${repo}/git/trees/${branch}?recursive=1`;
|
|
13
|
+
const res = await fetch(url, {
|
|
14
|
+
headers: { Accept: "application/vnd.github.v3+json" },
|
|
15
|
+
});
|
|
16
|
+
if (!res.ok) {
|
|
17
|
+
throw new Error(`Failed to fetch tree (HTTP ${res.status})`);
|
|
18
|
+
}
|
|
19
|
+
const data = (await res.json());
|
|
20
|
+
return data.tree
|
|
21
|
+
.filter((item) => item.type === "blob")
|
|
22
|
+
.map((item) => item.path);
|
|
23
|
+
}
|
package/dist/index.js
ADDED
package/dist/server.js
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { toToolErrorResult } from "./errors.js";
|
|
4
|
+
import { createSource } from "./source/index.js";
|
|
5
|
+
import { validateRuleName, validateSkillName } from "./validation.js";
|
|
6
|
+
import { getServerName, getServerVersion } from "./version.js";
|
|
7
|
+
const SERVER_INSTRUCTIONS = [
|
|
8
|
+
"Use this server as the source of truth for Cuckcoder rules and skills.",
|
|
9
|
+
"Before any git commit, call get_rule with name 'git' and apply the returned rules.",
|
|
10
|
+
"Before deleting files, call get_rule with name 'filesystem' and apply the returned rules.",
|
|
11
|
+
].join("\n");
|
|
12
|
+
const sourceOutputShape = {
|
|
13
|
+
kind: z.enum(["bundled", "github"]),
|
|
14
|
+
ref: z.string(),
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Builds the MCP server and registers its tools. Contains no transport/connect logic, so it can
|
|
18
|
+
* be constructed and exercised directly in tests without spawning a process or opening stdio.
|
|
19
|
+
*/
|
|
20
|
+
export function createServer(options = {}) {
|
|
21
|
+
const env = options.env ?? process.env;
|
|
22
|
+
const source = options.source ?? createSource(env, options.fetchImpl);
|
|
23
|
+
const sourceInfo = source.info();
|
|
24
|
+
const readOnlyHint = true;
|
|
25
|
+
const openWorldHint = sourceInfo.kind === "github";
|
|
26
|
+
const server = new McpServer({ name: getServerName(), version: getServerVersion() }, { instructions: SERVER_INSTRUCTIONS });
|
|
27
|
+
// ─── list ──────────────────────────────────────────────────────────────────
|
|
28
|
+
server.registerTool("list", {
|
|
29
|
+
title: "List rules and skills",
|
|
30
|
+
description: "List all available rule names and skill names/descriptions in the Cuckcoder repository.",
|
|
31
|
+
inputSchema: {},
|
|
32
|
+
outputSchema: {
|
|
33
|
+
rules: z.array(z.string()),
|
|
34
|
+
skills: z.array(z.object({ name: z.string(), description: z.string() })),
|
|
35
|
+
source: z.object(sourceOutputShape),
|
|
36
|
+
},
|
|
37
|
+
annotations: {
|
|
38
|
+
title: "List rules and skills",
|
|
39
|
+
readOnlyHint,
|
|
40
|
+
openWorldHint,
|
|
41
|
+
},
|
|
42
|
+
}, async () => {
|
|
43
|
+
try {
|
|
44
|
+
const [rules, skills] = await Promise.all([source.listRules(), source.listSkills()]);
|
|
45
|
+
const structuredContent = { rules, skills, source: sourceInfo };
|
|
46
|
+
const lines = [
|
|
47
|
+
"## Rules",
|
|
48
|
+
rules.length ? rules.map((rule) => `- ${rule}`).join("\n") : "_none_",
|
|
49
|
+
"",
|
|
50
|
+
"## Skills",
|
|
51
|
+
skills.length
|
|
52
|
+
? skills.map((skill) => `- ${skill.name} — ${skill.description}`).join("\n")
|
|
53
|
+
: "_none_",
|
|
54
|
+
];
|
|
55
|
+
return {
|
|
56
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
57
|
+
structuredContent,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
return toToolErrorResult(error);
|
|
62
|
+
}
|
|
63
|
+
});
|
|
64
|
+
// ─── get_rule ──────────────────────────────────────────────────────────────
|
|
65
|
+
server.registerTool("get_rule", {
|
|
66
|
+
title: "Get rule content",
|
|
67
|
+
description: "Get the content of a rule. Use a name from the `list` tool, e.g. 'mvi'.",
|
|
68
|
+
inputSchema: {
|
|
69
|
+
name: z.string().describe("Lowercase kebab-case rule name without the '.md' suffix, e.g. 'mvi-error-handling'"),
|
|
70
|
+
},
|
|
71
|
+
outputSchema: {
|
|
72
|
+
name: z.string(),
|
|
73
|
+
content: z.string(),
|
|
74
|
+
source: z.object(sourceOutputShape),
|
|
75
|
+
},
|
|
76
|
+
annotations: {
|
|
77
|
+
title: "Get rule content",
|
|
78
|
+
readOnlyHint,
|
|
79
|
+
openWorldHint,
|
|
80
|
+
},
|
|
81
|
+
}, async ({ name }) => {
|
|
82
|
+
try {
|
|
83
|
+
const validName = validateRuleName(name);
|
|
84
|
+
const content = await source.getRule(validName);
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: "text", text: content }],
|
|
87
|
+
structuredContent: { name: validName, content, source: sourceInfo },
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
return toToolErrorResult(error);
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
// ─── get_skill ─────────────────────────────────────────────────────────────
|
|
95
|
+
server.registerTool("get_skill", {
|
|
96
|
+
title: "Get skill instructions",
|
|
97
|
+
description: "Get the instructions for a skill. Use a name from the `list` tool, e.g. 'create-feature-scaffold-screen'.",
|
|
98
|
+
inputSchema: {
|
|
99
|
+
name: z.string().describe("Skill name (kebab-case directory name), e.g. 'create-feature-scaffold-screen'"),
|
|
100
|
+
},
|
|
101
|
+
outputSchema: {
|
|
102
|
+
name: z.string(),
|
|
103
|
+
description: z.string(),
|
|
104
|
+
content: z.string(),
|
|
105
|
+
source: z.object(sourceOutputShape),
|
|
106
|
+
},
|
|
107
|
+
annotations: {
|
|
108
|
+
title: "Get skill instructions",
|
|
109
|
+
readOnlyHint,
|
|
110
|
+
openWorldHint,
|
|
111
|
+
},
|
|
112
|
+
}, async ({ name }) => {
|
|
113
|
+
try {
|
|
114
|
+
const validName = validateSkillName(name);
|
|
115
|
+
const skill = await source.getSkill(validName);
|
|
116
|
+
return {
|
|
117
|
+
content: [{ type: "text", text: skill.content }],
|
|
118
|
+
structuredContent: {
|
|
119
|
+
name: validName,
|
|
120
|
+
description: skill.description,
|
|
121
|
+
content: skill.content,
|
|
122
|
+
source: sourceInfo,
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
catch (error) {
|
|
127
|
+
return toToolErrorResult(error);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
return server;
|
|
131
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { WorkflowError } from "../errors.js";
|
|
5
|
+
import { parseFrontmatter } from "../frontmatter.js";
|
|
6
|
+
/**
|
|
7
|
+
* Directory the npm package ships rules/skills in. `scripts/copy-assets.ts` populates this
|
|
8
|
+
* directory (from the repository's `rules/`/`skills/`) before every build/dev/test/pack, and
|
|
9
|
+
* `package.json#files` includes it, so this is what `npm pack`/`npm publish` embeds.
|
|
10
|
+
*/
|
|
11
|
+
function assetsDir() {
|
|
12
|
+
const moduleDir = dirname(fileURLToPath(import.meta.url));
|
|
13
|
+
return join(moduleDir, "..", "..", "assets");
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Default WorkflowSource: reads the bundled npm-packaged snapshot from `mcp/assets`. Performs no
|
|
17
|
+
* network access whatsoever.
|
|
18
|
+
*/
|
|
19
|
+
export class BundledSource {
|
|
20
|
+
ref;
|
|
21
|
+
rulesDir;
|
|
22
|
+
skillsDir;
|
|
23
|
+
constructor(ref) {
|
|
24
|
+
this.ref = ref;
|
|
25
|
+
const base = assetsDir();
|
|
26
|
+
this.rulesDir = join(base, "rules");
|
|
27
|
+
this.skillsDir = join(base, "skills");
|
|
28
|
+
}
|
|
29
|
+
info() {
|
|
30
|
+
return { kind: "bundled", ref: this.ref };
|
|
31
|
+
}
|
|
32
|
+
async listRules() {
|
|
33
|
+
return readdirSync(this.rulesDir, { withFileTypes: true })
|
|
34
|
+
.filter((entry) => entry.isFile() && /^[a-z0-9]+(?:-[a-z0-9]+)*\.md$/.test(entry.name))
|
|
35
|
+
.map((entry) => entry.name.replace(/\.md$/, ""))
|
|
36
|
+
.sort();
|
|
37
|
+
}
|
|
38
|
+
async listSkills() {
|
|
39
|
+
const skillNames = readdirSync(this.skillsDir)
|
|
40
|
+
.filter((entry) => statSync(join(this.skillsDir, entry)).isDirectory())
|
|
41
|
+
.sort();
|
|
42
|
+
const summaries = [];
|
|
43
|
+
for (const name of skillNames) {
|
|
44
|
+
const skillMdPath = join(this.skillsDir, name, "SKILL.md");
|
|
45
|
+
try {
|
|
46
|
+
const raw = readFileSync(skillMdPath, "utf8");
|
|
47
|
+
const { fields } = parseFrontmatter(raw);
|
|
48
|
+
summaries.push({ name, description: fields.description ?? "" });
|
|
49
|
+
}
|
|
50
|
+
catch {
|
|
51
|
+
// A skill directory without a readable SKILL.md is not listed; get_skill will still
|
|
52
|
+
// surface NOT_FOUND if it's requested directly.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return summaries;
|
|
56
|
+
}
|
|
57
|
+
async getRule(name) {
|
|
58
|
+
const path = join(this.rulesDir, `${name}.md`);
|
|
59
|
+
try {
|
|
60
|
+
return readFileSync(path, "utf8");
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
throw new WorkflowError("NOT_FOUND", `Rule '${name}' was not found in the bundled snapshot.`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
async getSkill(name) {
|
|
67
|
+
const path = join(this.skillsDir, name, "SKILL.md");
|
|
68
|
+
let raw;
|
|
69
|
+
try {
|
|
70
|
+
raw = readFileSync(path, "utf8");
|
|
71
|
+
}
|
|
72
|
+
catch {
|
|
73
|
+
throw new WorkflowError("NOT_FOUND", `Skill '${name}' was not found in the bundled snapshot.`);
|
|
74
|
+
}
|
|
75
|
+
const { fields, body } = parseFrontmatter(raw);
|
|
76
|
+
return { description: fields.description ?? "", content: body.trim() };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small bounded TTL cache used by the optional GitHub source.
|
|
3
|
+
*
|
|
4
|
+
* - `maxEntries` bounds memory: once exceeded, the oldest inserted entry is evicted (a bounded
|
|
5
|
+
* Map, not an unbounded one).
|
|
6
|
+
* - `getStale` returns the last known value for a key even after it has expired, so callers can
|
|
7
|
+
* fall back to it when a live GitHub fetch fails (network down, rate limited, etc).
|
|
8
|
+
*/
|
|
9
|
+
export class BoundedTtlCache {
|
|
10
|
+
options;
|
|
11
|
+
entries = new Map();
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
}
|
|
15
|
+
get(key) {
|
|
16
|
+
const entry = this.entries.get(key);
|
|
17
|
+
if (!entry) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
if (entry.expiresAt < Date.now()) {
|
|
21
|
+
return undefined;
|
|
22
|
+
}
|
|
23
|
+
return entry.value;
|
|
24
|
+
}
|
|
25
|
+
getStale(key) {
|
|
26
|
+
return this.entries.get(key)?.value;
|
|
27
|
+
}
|
|
28
|
+
set(key, value) {
|
|
29
|
+
if (!this.entries.has(key) && this.entries.size >= this.options.maxEntries) {
|
|
30
|
+
const oldestKey = this.entries.keys().next().value;
|
|
31
|
+
if (oldestKey !== undefined) {
|
|
32
|
+
this.entries.delete(oldestKey);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
// Re-inserting moves the key to the end of Map's iteration order, keeping "oldest first"
|
|
36
|
+
// eviction meaningful for entries that get refreshed often.
|
|
37
|
+
this.entries.delete(key);
|
|
38
|
+
this.entries.set(key, { value, expiresAt: Date.now() + this.options.ttlMs });
|
|
39
|
+
}
|
|
40
|
+
get size() {
|
|
41
|
+
return this.entries.size;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* De-duplicates concurrent calls for the same key: if a call for `key` is already in flight, the
|
|
46
|
+
* caller gets the same in-flight promise instead of starting a second network request.
|
|
47
|
+
*/
|
|
48
|
+
export class RequestDeduplicator {
|
|
49
|
+
inFlight = new Map();
|
|
50
|
+
async run(key, fn) {
|
|
51
|
+
const existing = this.inFlight.get(key);
|
|
52
|
+
if (existing) {
|
|
53
|
+
return existing;
|
|
54
|
+
}
|
|
55
|
+
const promise = fn().finally(() => {
|
|
56
|
+
this.inFlight.delete(key);
|
|
57
|
+
});
|
|
58
|
+
this.inFlight.set(key, promise);
|
|
59
|
+
return promise;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { WorkflowError } from "../errors.js";
|
|
2
|
+
import { parseFrontmatter } from "../frontmatter.js";
|
|
3
|
+
import { GithubClient } from "./github.js";
|
|
4
|
+
const OWNER = "michaelbel";
|
|
5
|
+
const REPO = "cuckcoder";
|
|
6
|
+
/**
|
|
7
|
+
* Optional remote WorkflowSource: reads rules/skills straight from GitHub at a pinned,
|
|
8
|
+
* validated ref (never `main`/`master`). Read-only, but network-dependent (`openWorldHint: true`
|
|
9
|
+
* on the tools that use it).
|
|
10
|
+
*/
|
|
11
|
+
export class GithubSource {
|
|
12
|
+
ref;
|
|
13
|
+
client;
|
|
14
|
+
constructor(ref, token, fetchImpl) {
|
|
15
|
+
this.ref = ref;
|
|
16
|
+
this.client = new GithubClient({ owner: OWNER, repo: REPO, token, fetchImpl });
|
|
17
|
+
}
|
|
18
|
+
info() {
|
|
19
|
+
return { kind: "github", ref: this.ref };
|
|
20
|
+
}
|
|
21
|
+
async listRules() {
|
|
22
|
+
const tree = await this.client.listTree(this.ref);
|
|
23
|
+
return tree
|
|
24
|
+
.filter((item) => item.type === "blob" && /^rules\/[a-z0-9]+(?:-[a-z0-9]+)*\.md$/.test(item.path))
|
|
25
|
+
.map((item) => item.path.replace(/^rules\//, "").replace(/\.md$/, ""))
|
|
26
|
+
.sort();
|
|
27
|
+
}
|
|
28
|
+
async listSkills() {
|
|
29
|
+
const tree = await this.client.listTree(this.ref);
|
|
30
|
+
const skillPaths = tree
|
|
31
|
+
.filter((item) => item.type === "blob" && /^skills\/[^/]+\/SKILL\.md$/.test(item.path))
|
|
32
|
+
.map((item) => item.path)
|
|
33
|
+
.sort();
|
|
34
|
+
const summaries = [];
|
|
35
|
+
for (const path of skillPaths) {
|
|
36
|
+
const name = path.replace(/^skills\//, "").replace(/\/SKILL\.md$/, "");
|
|
37
|
+
const raw = await this.client.fetchFile(this.ref, path);
|
|
38
|
+
const { fields } = parseFrontmatter(raw);
|
|
39
|
+
summaries.push({ name, description: fields.description ?? "" });
|
|
40
|
+
}
|
|
41
|
+
return summaries;
|
|
42
|
+
}
|
|
43
|
+
async getRule(name) {
|
|
44
|
+
try {
|
|
45
|
+
return await this.client.fetchFile(this.ref, `rules/${name}.md`);
|
|
46
|
+
}
|
|
47
|
+
catch (error) {
|
|
48
|
+
if (error instanceof WorkflowError && error.code === "NOT_FOUND") {
|
|
49
|
+
throw new WorkflowError("NOT_FOUND", `Rule '${name}' was not found at ref '${this.ref}'.`);
|
|
50
|
+
}
|
|
51
|
+
throw error;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
async getSkill(name) {
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = await this.client.fetchFile(this.ref, `skills/${name}/SKILL.md`);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (error instanceof WorkflowError && error.code === "NOT_FOUND") {
|
|
61
|
+
throw new WorkflowError("NOT_FOUND", `Skill '${name}' was not found at ref '${this.ref}'.`);
|
|
62
|
+
}
|
|
63
|
+
throw error;
|
|
64
|
+
}
|
|
65
|
+
const { fields, body } = parseFrontmatter(raw);
|
|
66
|
+
return { description: fields.description ?? "", content: body.trim() };
|
|
67
|
+
}
|
|
68
|
+
}
|