division-ds 0.1.2 → 0.2.1
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/AGENTS.md +23 -3
- package/README.md +11 -0
- package/division-ds-0.2.1.tgz +0 -0
- package/package.json +2 -2
- package/public/docs/components/accordion.md +207 -0
- package/public/docs/components/ai-label.md +90 -0
- package/public/docs/components/alert-dialog.md +292 -0
- package/public/docs/components/alert.md +175 -0
- package/public/docs/components/approval-request.md +148 -0
- package/public/docs/components/auth-form.md +132 -0
- package/public/docs/components/avatar-group.md +152 -0
- package/public/docs/components/avatar.md +179 -0
- package/public/docs/components/badge.md +178 -0
- package/public/docs/components/breadcrumbs.md +112 -0
- package/public/docs/components/button.md +204 -0
- package/public/docs/components/card.md +257 -0
- package/public/docs/components/carousel.md +130 -0
- package/public/docs/components/cart.md +172 -0
- package/public/docs/components/chart-panel.md +358 -0
- package/public/docs/components/chat-message.md +157 -0
- package/public/docs/components/chat-thread.md +244 -0
- package/public/docs/components/checkbox.md +149 -0
- package/public/docs/components/checkout-form.md +128 -0
- package/public/docs/components/command-palette.md +147 -0
- package/public/docs/components/data-table.md +401 -0
- package/public/docs/components/description-list.md +103 -0
- package/public/docs/components/dialog.md +197 -0
- package/public/docs/components/divider.md +97 -0
- package/public/docs/components/dropdown-menu.md +222 -0
- package/public/docs/components/empty-state.md +145 -0
- package/public/docs/components/footer.md +397 -0
- package/public/docs/components/form-field.md +216 -0
- package/public/docs/components/icon-button.md +127 -0
- package/public/docs/components/input.md +200 -0
- package/public/docs/components/kanban-board.md +300 -0
- package/public/docs/components/kbd.md +72 -0
- package/public/docs/components/link.md +98 -0
- package/public/docs/components/list-item.md +241 -0
- package/public/docs/components/loading-animation.md +72 -0
- package/public/docs/components/multi-select.md +202 -0
- package/public/docs/components/navbar.md +144 -0
- package/public/docs/components/newsletter-form.md +112 -0
- package/public/docs/components/page-header.md +151 -0
- package/public/docs/components/pagination.md +212 -0
- package/public/docs/components/product-card.md +172 -0
- package/public/docs/components/progress.md +101 -0
- package/public/docs/components/prompt-input.md +151 -0
- package/public/docs/components/quantity-stepper.md +140 -0
- package/public/docs/components/radio.md +177 -0
- package/public/docs/components/rating.md +106 -0
- package/public/docs/components/score-ring.md +95 -0
- package/public/docs/components/search.md +116 -0
- package/public/docs/components/section-heading.md +113 -0
- package/public/docs/components/segmented-control.md +126 -0
- package/public/docs/components/select.md +224 -0
- package/public/docs/components/sheet.md +145 -0
- package/public/docs/components/sidebar.md +193 -0
- package/public/docs/components/skeleton.md +135 -0
- package/public/docs/components/slider.md +114 -0
- package/public/docs/components/sparkline.md +128 -0
- package/public/docs/components/spinner.md +98 -0
- package/public/docs/components/stat.md +106 -0
- package/public/docs/components/status-tracker.md +145 -0
- package/public/docs/components/stepper.md +147 -0
- package/public/docs/components/switch.md +153 -0
- package/public/docs/components/tabs.md +180 -0
- package/public/docs/components/tag.md +105 -0
- package/public/docs/components/textarea.md +148 -0
- package/public/docs/components/timeline.md +152 -0
- package/public/docs/components/toast.md +194 -0
- package/public/docs/components/tool-call.md +107 -0
- package/public/docs/components/tooltip.md +108 -0
- package/public/manifest/components.json +198 -15
- package/public/manifest/tokens.json +75 -5
- package/scripts/division-ds.mjs +78 -42
- package/scripts/lib/agent-docs.mjs +80 -6
- package/scripts/lib/ai-copy.mjs +120 -0
- package/scripts/lib/button-spacing.mjs +5 -5
- package/scripts/lib/check-text.mjs +57 -0
- package/scripts/lib/contrast.mjs +111 -0
- package/scripts/lib/host-inline.mjs +59 -0
- package/scripts/lib/mcp-server.mjs +116 -0
- package/scripts/lib/mcp-tools.mjs +266 -0
- package/scripts/lib/props-validator.mjs +3 -2
- package/scripts/lib/rules-installer.mjs +42 -4
- package/scripts/lib/token-discipline.mjs +10 -3
- package/skills/division-ds/SKILL.md +90 -0
- package/skills/division-ds/commands/add.md +25 -0
- package/skills/division-ds/commands/build.md +46 -0
- package/skills/division-ds/commands/fix.md +28 -0
- package/skills/division-ds/commands/review.md +25 -0
- package/skills/division-ds/topics/ai.md +8 -0
- package/skills/division-ds/topics/colour.md +11 -0
- package/skills/division-ds/topics/feedback.md +9 -0
- package/skills/division-ds/topics/forms.md +22 -0
- package/skills/division-ds/topics/layout.md +36 -0
- package/skills/division-ds/topics/motion.md +10 -0
- package/skills/division-ds/topics/overlays.md +9 -0
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Contrast maths for the AA gate. Two jobs: parse a colour, and composite it over whatever it is
|
|
2
|
+
// actually painted on before measuring.
|
|
3
|
+
//
|
|
4
|
+
// The compositing half exists because this design system writes real translucent tokens
|
|
5
|
+
// (`border.hairline`, `surface.raised`), and a hex-only parser silently could not evaluate them —
|
|
6
|
+
// which is how a whole class of SC 1.4.11 failure on the form controls' boundaries stayed
|
|
7
|
+
// invisible to a gate that reported 28/28 green. A translucent colour has no contrast ratio of
|
|
8
|
+
// its own; it only has one against a named backdrop. So every alpha-carrying colour here must be
|
|
9
|
+
// given one, and the gate names it explicitly rather than guessing.
|
|
10
|
+
|
|
11
|
+
/** Parses #rgb, #rrggbb, #rrggbbaa, rgb()/rgba() (comma or space separated, alpha as number or
|
|
12
|
+
* percentage) into `[r, g, b, a]` with channels 0–255 and alpha 0–1. */
|
|
13
|
+
function parseColor(color) {
|
|
14
|
+
if (typeof color !== 'string') throw new Error(`Not a colour: ${String(color)}`)
|
|
15
|
+
const value = color.trim()
|
|
16
|
+
|
|
17
|
+
if (value.startsWith('#')) {
|
|
18
|
+
const hex = value.slice(1)
|
|
19
|
+
if (hex.length === 3 || hex.length === 4) {
|
|
20
|
+
const channels = [...hex].map(c => parseInt(c + c, 16))
|
|
21
|
+
return [channels[0], channels[1], channels[2], channels[3] === undefined ? 1 : channels[3] / 255]
|
|
22
|
+
}
|
|
23
|
+
if (hex.length === 6 || hex.length === 8) {
|
|
24
|
+
const channels = [0, 2, 4, 6]
|
|
25
|
+
.filter(i => i < hex.length)
|
|
26
|
+
.map(i => parseInt(hex.slice(i, i + 2), 16))
|
|
27
|
+
return [channels[0], channels[1], channels[2], channels[3] === undefined ? 1 : channels[3] / 255]
|
|
28
|
+
}
|
|
29
|
+
throw new Error(`Unparseable hex colour: ${color}`)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const fn = value.match(/^rgba?\(([^)]*)\)$/i)
|
|
33
|
+
if (fn) {
|
|
34
|
+
const parts = fn[1].split(/[\s,/]+/).filter(Boolean)
|
|
35
|
+
if (parts.length < 3) throw new Error(`Unparseable rgb() colour: ${color}`)
|
|
36
|
+
const channel = part =>
|
|
37
|
+
part.endsWith('%') ? (parseFloat(part) / 100) * 255 : parseFloat(part)
|
|
38
|
+
const alpha = parts[3] === undefined
|
|
39
|
+
? 1
|
|
40
|
+
: parts[3].endsWith('%')
|
|
41
|
+
? parseFloat(parts[3]) / 100
|
|
42
|
+
: parseFloat(parts[3])
|
|
43
|
+
const [r, g, b] = parts.slice(0, 3).map(channel)
|
|
44
|
+
if ([r, g, b, alpha].some(Number.isNaN)) throw new Error(`Unparseable rgb() colour: ${color}`)
|
|
45
|
+
return [r, g, b, alpha]
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
throw new Error(`Unsupported colour format: ${color} (expected #hex or rgb()/rgba())`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const toHex = n => Math.round(Math.min(255, Math.max(0, n))).toString(16).padStart(2, '0')
|
|
52
|
+
|
|
53
|
+
/** Source-over compositing: paints `color` onto `backdrop` and returns the resulting opaque hex.
|
|
54
|
+
* An opaque `color` is returned unchanged (and needs no backdrop). A translucent one without a
|
|
55
|
+
* backdrop throws rather than silently measuring its own nominal RGB, which is exactly the
|
|
56
|
+
* mistake the old hex-only parser could not even make visible. */
|
|
57
|
+
export function composite(color, backdrop) {
|
|
58
|
+
const [r, g, b, a] = parseColor(color)
|
|
59
|
+
if (a >= 1) return `#${toHex(r)}${toHex(g)}${toHex(b)}`
|
|
60
|
+
if (backdrop === undefined || backdrop === null) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${color} is translucent (alpha ${a}) and has no contrast ratio on its own — ` +
|
|
63
|
+
'pass the colour it is painted over as the backdrop.'
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
const [br, bg, bb] = parseColor(composite(backdrop))
|
|
67
|
+
return `#${toHex(r * a + br * (1 - a))}${toHex(g * a + bg * (1 - a))}${toHex(b * a + bb * (1 - a))}`
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function relativeLuminance(color) {
|
|
71
|
+
const [r, g, b] = parseColor(color)
|
|
72
|
+
const linear = [r, g, b]
|
|
73
|
+
.map(v => v / 255)
|
|
74
|
+
.map(v => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4))
|
|
75
|
+
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2]
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Contrast ratio between `a` (the foreground) and `b` (its background).
|
|
80
|
+
*
|
|
81
|
+
* `backdrop` is what `b` itself is painted on, and is only needed when a translucent colour is
|
|
82
|
+
* involved: `b` is composited over `backdrop`, then `a` is composited over that result — the
|
|
83
|
+
* same order the browser paints them in. Two opaque hexes behave exactly as before, so every
|
|
84
|
+
* existing caller (`generate-ramps.mjs`, `ColorGuideline.tsx`) is unaffected.
|
|
85
|
+
*/
|
|
86
|
+
export function contrastRatio(a, b, backdrop) {
|
|
87
|
+
const bg = composite(b, backdrop)
|
|
88
|
+
const fg = composite(a, bg)
|
|
89
|
+
const [lighter, darker] = [relativeLuminance(fg), relativeLuminance(bg)].sort((x, y) => y - x)
|
|
90
|
+
return (lighter + 0.05) / (darker + 0.05)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Measures every pair and says whether each one holds.
|
|
95
|
+
*
|
|
96
|
+
* `maxRatio` is optional and is a CEILING, which most pairs have no use for: an accessibility
|
|
97
|
+
* minimum is a floor and more contrast is never a failure. One role in this system is the
|
|
98
|
+
* exception. The dimmed leading-zero pad on a hero numeral is decorative and `aria-hidden`, so no
|
|
99
|
+
* SC 1.4.3 obligation attaches to it at all, and its actual design rule is the opposite shape:
|
|
100
|
+
* it must stay well UNDER the ink it pads or it stops reading as padding and starts reading as a
|
|
101
|
+
* digit. That is exactly what happened in light theme, where the pad measured 65% of ink contrast
|
|
102
|
+
* while dark measured 31%, and a floor-only gate could not see it. A rule that is not a gate is a
|
|
103
|
+
* suggestion, so the gate learned to express a band.
|
|
104
|
+
*/
|
|
105
|
+
export function checkPairs(pairs) {
|
|
106
|
+
return pairs.map(({ name, fg, bg, backdrop, minRatio, maxRatio }) => {
|
|
107
|
+
const ratio = contrastRatio(fg, bg, backdrop)
|
|
108
|
+
const pass = ratio >= minRatio && (maxRatio === undefined || ratio <= maxRatio)
|
|
109
|
+
return { name, ratio, minRatio, maxRatio, pass }
|
|
110
|
+
})
|
|
111
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// The inline-card limits of MCP Apps hosts, as a gate. Claude's design guidelines and OpenAI's
|
|
2
|
+
// Apps SDK UI guidelines agree on an inline card: at most two actions, no nested scrolling (the
|
|
3
|
+
// card auto-fits its content and the host clips what overflows), and no menus or popovers (they
|
|
4
|
+
// are clipped by the iframe or fight the host's z-index). A screen opts in by marking the card:
|
|
5
|
+
// `<Card data-host-mode="inline">`. Only marked elements are judged.
|
|
6
|
+
//
|
|
7
|
+
// It reuses button-spacing's JSX scanner, so it has the same blind spots: mapped or conditional
|
|
8
|
+
// children, and anything rendered by a component defined elsewhere, are invisible to it.
|
|
9
|
+
import { buildTree, isButtonLike, lineColAt, lineIndex, tokenizeTags } from './button-spacing.mjs'
|
|
10
|
+
|
|
11
|
+
export const MAX_INLINE_ACTIONS = 2
|
|
12
|
+
|
|
13
|
+
/** Division components that open a floating layer: a menu, a listbox or a tooltip. */
|
|
14
|
+
export const POPOVER_TAGS = new Set(['DropdownMenu', 'Select', 'MultiSelect', 'Tooltip', 'CommandPalette', 'Dialog', 'Sheet', 'AlertDialog'])
|
|
15
|
+
|
|
16
|
+
const SCROLL_CLASS = /(^|[\s'"`])overflow(-[xy])?-(auto|scroll)(?=[\s'"`]|$)/
|
|
17
|
+
|
|
18
|
+
const isInline = node => node.attrs.get('data-host-mode') === 'inline'
|
|
19
|
+
|
|
20
|
+
/** Every rule an inline-marked element in `text` breaks: `{ line, column, rule, found, fix }`. */
|
|
21
|
+
export function findInlineViolations(text) {
|
|
22
|
+
const tree = buildTree(tokenizeTags(text))
|
|
23
|
+
const offsets = lineIndex(text)
|
|
24
|
+
const violations = []
|
|
25
|
+
const at = (node, rule, found, fix) => violations.push({ ...lineColAt(offsets, node.start), rule, found, fix })
|
|
26
|
+
|
|
27
|
+
function judge(card) {
|
|
28
|
+
let actions = 0
|
|
29
|
+
const walk = node => {
|
|
30
|
+
for (const child of node.children) {
|
|
31
|
+
// An asChild wrapper and its Button are one action: count it and do not descend.
|
|
32
|
+
if (isButtonLike(child)) {
|
|
33
|
+
actions++
|
|
34
|
+
continue
|
|
35
|
+
}
|
|
36
|
+
if (POPOVER_TAGS.has(child.name)) {
|
|
37
|
+
at(child, 'inline-no-popover', `<${child.name}> inside an inline card`, 'Use visible controls instead (SegmentedControl, Switch, Radio), or move this to fullscreen.')
|
|
38
|
+
}
|
|
39
|
+
if (SCROLL_CLASS.test(child.attrs.get('className') ?? '')) {
|
|
40
|
+
at(child, 'inline-no-nested-scroll', `<${child.name}> scrolls inside an inline card`, 'Let the card grow to its content, or request fullscreen for a scrolling view.')
|
|
41
|
+
}
|
|
42
|
+
walk(child)
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
walk(card)
|
|
46
|
+
if (actions > MAX_INLINE_ACTIONS) {
|
|
47
|
+
at(card, 'inline-max-actions', `${actions} actions in an inline card`, `Keep at most ${MAX_INLINE_ACTIONS} (one primary, one secondary); put the rest in fullscreen or in the chat.`)
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const visit = node => {
|
|
52
|
+
if (node.name !== null && isInline(node)) return judge(node)
|
|
53
|
+
node.children.forEach(visit)
|
|
54
|
+
}
|
|
55
|
+
visit(tree)
|
|
56
|
+
return violations.sort((a, b) => a.line - b.line || a.column - b.column)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export const formatInlineViolation = (v, path) => `${path}:${v.line}:${v.column} ${v.found} (${v.rule}). ${v.fix}`
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
// A Model Context Protocol server over stdio, for `division-ds mcp`. Hand-written rather than
|
|
2
|
+
// built on @modelcontextprotocol/sdk: the npm package has one dependency (typescript) and a tools-
|
|
3
|
+
// only server needs four methods. The transport is one JSON-RPC message per line on stdin and
|
|
4
|
+
// stdout; anything written to stdout that is not a message breaks the client, so logs go to stderr.
|
|
5
|
+
//
|
|
6
|
+
// `handle` is pure (a message in, a reply or nothing out) so the tests drive it without a process.
|
|
7
|
+
import { ToolError } from './mcp-tools.mjs'
|
|
8
|
+
|
|
9
|
+
// Newest first. A client asking for one of these gets it back; any other gets the newest, and the
|
|
10
|
+
// client decides whether it can work with that (the spec's negotiation).
|
|
11
|
+
export const PROTOCOL_VERSIONS = ['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']
|
|
12
|
+
|
|
13
|
+
const INSTRUCTIONS = `Division DS: a React 19, Tailwind 4 and Radix design system.
|
|
14
|
+
Before writing a component, call get_component for its props and examples. Use only tokens from get_tokens or search, as var(--name).
|
|
15
|
+
Before saying a screen is done, call validate_code on each file you wrote (.css too) and fix every finding, then go through get_review_checklist.
|
|
16
|
+
Shared helpers (theme toggle, form errors, money, links): https://victor-ilioiu.vercel.app/systems/division-ds/docs/helpers.md`
|
|
17
|
+
|
|
18
|
+
/** A JSON-RPC error reply. */
|
|
19
|
+
const failure = (id, code, message) => ({ jsonrpc: '2.0', id, error: { code, message } })
|
|
20
|
+
|
|
21
|
+
/** The first problem with `args` against a tool's input schema, or undefined. Only what the tools
|
|
22
|
+
* declare: required keys, unknown keys, and the JSON type of each value. */
|
|
23
|
+
export function argumentProblem(schema, args) {
|
|
24
|
+
if (args === null || typeof args !== 'object' || Array.isArray(args)) return 'arguments must be an object.'
|
|
25
|
+
const properties = schema.properties ?? {}
|
|
26
|
+
for (const key of schema.required ?? []) if (args[key] === undefined) return `${key} is required.`
|
|
27
|
+
for (const [key, value] of Object.entries(args)) {
|
|
28
|
+
const property = properties[key]
|
|
29
|
+
if (!property) return `Unknown argument ${key}. This tool takes: ${Object.keys(properties).join(', ') || 'nothing'}.`
|
|
30
|
+
const type = Array.isArray(value) ? 'array' : Number.isInteger(value) ? 'integer' : typeof value
|
|
31
|
+
const ok = property.type === type || (property.type === 'number' && type === 'integer')
|
|
32
|
+
if (!ok) return `${key} must be ${/^[aeiou]/.test(property.type) ? 'an' : 'a'} ${property.type}.`
|
|
33
|
+
if (property.enum && !property.enum.includes(value)) return `${key} is one of ${property.enum.join(', ')}.`
|
|
34
|
+
if (property.type === 'array' && property.items?.type && value.some(item => typeof item !== property.items.type)) return `${key} holds ${property.items.type}s.`
|
|
35
|
+
}
|
|
36
|
+
return undefined
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function createServer({ tools, version }) {
|
|
40
|
+
const byName = new Map(tools.map(tool => [tool.name, tool]))
|
|
41
|
+
|
|
42
|
+
function call(params) {
|
|
43
|
+
const tool = byName.get(params?.name)
|
|
44
|
+
if (!tool) return { content: [{ type: 'text', text: `No tool "${params?.name}". Tools: ${[...byName.keys()].join(', ')}.` }], isError: true }
|
|
45
|
+
const args = params.arguments ?? {}
|
|
46
|
+
const problem = argumentProblem(tool.inputSchema, args)
|
|
47
|
+
if (problem) return { content: [{ type: 'text', text: `${tool.name}: ${problem}` }], isError: true }
|
|
48
|
+
try {
|
|
49
|
+
return { content: [{ type: 'text', text: tool.run(args) }] }
|
|
50
|
+
} catch (error) {
|
|
51
|
+
// A ToolError is the agent's mistake and says how to fix it. Anything else is ours; it is
|
|
52
|
+
// still reported as a tool result, so one bad call does not take the session down.
|
|
53
|
+
const text = error instanceof ToolError ? error.message : `division-ds ${version} failed on ${tool.name}: ${error.message}`
|
|
54
|
+
return { content: [{ type: 'text', text }], isError: true }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** One parsed message in; the reply to send, or undefined for a notification. */
|
|
59
|
+
function handle(message) {
|
|
60
|
+
if (message === null || typeof message !== 'object' || message.jsonrpc !== '2.0' || typeof message.method !== 'string') {
|
|
61
|
+
return failure(message?.id ?? null, -32600, 'Invalid request.')
|
|
62
|
+
}
|
|
63
|
+
const { id, method, params } = message
|
|
64
|
+
// A notification (no id) never gets a reply, even one we do not understand.
|
|
65
|
+
if (id === undefined) return undefined
|
|
66
|
+
switch (method) {
|
|
67
|
+
case 'initialize': {
|
|
68
|
+
const asked = params?.protocolVersion
|
|
69
|
+
return {
|
|
70
|
+
jsonrpc: '2.0',
|
|
71
|
+
id,
|
|
72
|
+
result: {
|
|
73
|
+
protocolVersion: PROTOCOL_VERSIONS.includes(asked) ? asked : PROTOCOL_VERSIONS[0],
|
|
74
|
+
capabilities: { tools: {} },
|
|
75
|
+
serverInfo: { name: 'division-ds', version },
|
|
76
|
+
instructions: INSTRUCTIONS,
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
case 'ping':
|
|
81
|
+
return { jsonrpc: '2.0', id, result: {} }
|
|
82
|
+
case 'tools/list':
|
|
83
|
+
return { jsonrpc: '2.0', id, result: { tools: tools.map(({ name, description, inputSchema }) => ({ name, description, inputSchema })) } }
|
|
84
|
+
case 'tools/call':
|
|
85
|
+
return { jsonrpc: '2.0', id, result: call(params) }
|
|
86
|
+
default:
|
|
87
|
+
return failure(id, -32601, `Method not found: ${method}`)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Reads messages from `input` line by line and writes replies to `output`. */
|
|
92
|
+
function listen(input, output) {
|
|
93
|
+
let buffer = ''
|
|
94
|
+
const send = reply => reply && output.write(JSON.stringify(reply) + '\n')
|
|
95
|
+
input.setEncoding('utf8')
|
|
96
|
+
input.on('data', chunk => {
|
|
97
|
+
buffer += chunk
|
|
98
|
+
let newline
|
|
99
|
+
while ((newline = buffer.indexOf('\n')) !== -1) {
|
|
100
|
+
const line = buffer.slice(0, newline).trim()
|
|
101
|
+
buffer = buffer.slice(newline + 1)
|
|
102
|
+
if (!line) continue
|
|
103
|
+
let message
|
|
104
|
+
try {
|
|
105
|
+
message = JSON.parse(line)
|
|
106
|
+
} catch {
|
|
107
|
+
send(failure(null, -32700, 'Parse error: each line must be one JSON-RPC message.'))
|
|
108
|
+
continue
|
|
109
|
+
}
|
|
110
|
+
send(handle(message))
|
|
111
|
+
}
|
|
112
|
+
})
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return { handle, listen }
|
|
116
|
+
}
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
// The tools `division-ds mcp` serves, as plain functions over the two manifests. The protocol
|
|
2
|
+
// (JSON-RPC over stdio) lives in mcp-server.mjs; nothing here knows it exists, so every tool is
|
|
3
|
+
// unit-tested by calling it.
|
|
4
|
+
//
|
|
5
|
+
// Most design-system MCP servers stop at list / search / get docs (shadcn, Chakra, Dialtone,
|
|
6
|
+
// Blade, Vibe; surveyed 2026-10-25). `validate_code` is the one they lack: it runs the same gates
|
|
7
|
+
// as `division-ds check` on the code the agent has just written, before the agent says it is done.
|
|
8
|
+
import { alsoExports, INSTALLED_PATHS } from './agent-docs.mjs'
|
|
9
|
+
import { checkText } from './check-text.mjs'
|
|
10
|
+
import { contrastRatio } from './contrast.mjs'
|
|
11
|
+
import { buildIndex, closest } from './props-validator.mjs'
|
|
12
|
+
|
|
13
|
+
export const REGISTRY_BASE = 'https://raw.githubusercontent.com/Victor-Ilioiu/division-ds/main/public/r'
|
|
14
|
+
const TIERS = ['atoms', 'molecules', 'organisms']
|
|
15
|
+
const LAYERS = ['primitive', 'spacing', 'motion', 'semantic', 'component']
|
|
16
|
+
// WCAG 2.2 minimums: SC 1.4.3 for text, SC 1.4.11 for the boundary of a control or a graphic.
|
|
17
|
+
const MINIMUMS = { normal: 4.5, large: 3, ui: 3 }
|
|
18
|
+
|
|
19
|
+
const words = text => String(text).toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
|
|
20
|
+
|
|
21
|
+
/** `context`: { manifest, tokens, version, readDoc(slug) -> string|undefined, readChecklist() -> string|undefined } */
|
|
22
|
+
export function createTools(context) {
|
|
23
|
+
const { manifest, tokens, version } = context
|
|
24
|
+
const components = manifest.components
|
|
25
|
+
const index = buildIndex(manifest)
|
|
26
|
+
const tokenNames = tokens.tokens.map(token => token.name)
|
|
27
|
+
const tokenByName = new Map(tokens.tokens.map(token => [token.name, token]))
|
|
28
|
+
// Every reply says which data it came from, because the agent's project may have installed an
|
|
29
|
+
// older copy of the components than the manifest this server reads (Dialtone does the same).
|
|
30
|
+
const stamp = `\n\n(division-ds ${version}; data: ${components.length} components, ${tokenNames.length} tokens. It describes the current release; a project that installed the components earlier may differ.)`
|
|
31
|
+
|
|
32
|
+
function findComponent(name) {
|
|
33
|
+
const wanted = String(name ?? '').trim().toLowerCase()
|
|
34
|
+
return (
|
|
35
|
+
components.find(c => c.name.toLowerCase() === wanted || c.slug === wanted) ??
|
|
36
|
+
// A component documented on another's page (ResponseFeedback on ChatMessage's) opens that page.
|
|
37
|
+
components.find(c => alsoExports(c).some(other => other.toLowerCase() === wanted))
|
|
38
|
+
)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function unknownComponent(name) {
|
|
42
|
+
const guess = closest(String(name), components.map(c => c.name))
|
|
43
|
+
throw new ToolError(`No component "${name}".${guess ? ` Did you mean ${guess}?` : ''} list_components gives every name.`)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A token's value in one theme, following var() references down to a colour. */
|
|
47
|
+
function tokenValue(name, theme, seen = new Set()) {
|
|
48
|
+
if (seen.has(name)) throw new ToolError(`Token ${name} refers to itself.`)
|
|
49
|
+
seen.add(name)
|
|
50
|
+
const token = tokenByName.get(name)
|
|
51
|
+
if (!token) {
|
|
52
|
+
const guess = closest(name, tokenNames)
|
|
53
|
+
throw new ToolError(`No token ${name}.${guess ? ` Did you mean ${guess}?` : ''} get_tokens lists them.`)
|
|
54
|
+
}
|
|
55
|
+
const value = typeof token.value === 'object' ? token.value[theme] : token.value
|
|
56
|
+
const ref = /^var\((--[a-z0-9-]+)\)$/.exec(value)
|
|
57
|
+
return ref ? tokenValue(ref[1], theme, seen) : value
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const colour = (input, theme) => (String(input).startsWith('--') ? tokenValue(String(input), theme) : String(input))
|
|
61
|
+
|
|
62
|
+
const tools = [
|
|
63
|
+
{
|
|
64
|
+
name: 'list_components',
|
|
65
|
+
description: 'Every Division DS component with its tier and one line on when to use it. Start here to pick a component; then call get_component for its props and examples.',
|
|
66
|
+
inputSchema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
properties: { tier: { type: 'string', enum: TIERS, description: 'Only this tier.' } },
|
|
69
|
+
additionalProperties: false,
|
|
70
|
+
},
|
|
71
|
+
run({ tier } = {}) {
|
|
72
|
+
if (tier !== undefined && !TIERS.includes(tier)) throw new ToolError(`tier is one of ${TIERS.join(', ')}.`)
|
|
73
|
+
const rows = components.filter(c => !tier || c.tier === tier)
|
|
74
|
+
return rows
|
|
75
|
+
.map(c => {
|
|
76
|
+
const also = alsoExports(c)
|
|
77
|
+
return `- ${c.name} (${c.tier}): ${c.useWhen}${c.notFor ? ` Not for: ${c.notFor}` : ''}${also.length ? ` Also on its page: ${also.join(', ')}.` : ''}`
|
|
78
|
+
})
|
|
79
|
+
.join('\n')
|
|
80
|
+
},
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
name: 'get_component',
|
|
84
|
+
description: 'One component: import path, props with types and defaults, keyboard behaviour, do and don\'t, and working examples. Read it before writing the component, so props and values are real.',
|
|
85
|
+
inputSchema: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
properties: {
|
|
88
|
+
name: { type: 'string', description: 'Component name or slug, e.g. "Button" or "icon-button".' },
|
|
89
|
+
section: { type: 'string', enum: ['all', 'props'], description: '"props" returns only the import and the prop table. Default "all".' },
|
|
90
|
+
},
|
|
91
|
+
required: ['name'],
|
|
92
|
+
additionalProperties: false,
|
|
93
|
+
},
|
|
94
|
+
run({ name, section = 'all' }) {
|
|
95
|
+
const c = findComponent(name) ?? unknownComponent(name)
|
|
96
|
+
const imports = c.imports.map(i => `import { ${i.names.join(', ')} } from '${i.from}'`).join('\n')
|
|
97
|
+
const props = c.props.map(p => `- ${p.name}: ${p.type}${p.default ? ` = ${p.default}` : ''}. ${p.description ?? ''}`.trimEnd()).join('\n')
|
|
98
|
+
const head = `# ${c.name} (${c.tier})\n\n${c.summary}\n\n${imports}\n\n${INSTALLED_PATHS}\n\n## Props\n\n${props}`
|
|
99
|
+
if (section === 'props') return head
|
|
100
|
+
const page = context.readDoc?.(c.slug)
|
|
101
|
+
// The page is the full contract (examples, keyboard, do and don't, written for agents).
|
|
102
|
+
// Without it, fall back to the same facts from the manifest.
|
|
103
|
+
if (page) return page
|
|
104
|
+
const list = (title, items) => (items?.length ? `\n\n## ${title}\n\n${items.map(item => `- ${item}`).join('\n')}` : '')
|
|
105
|
+
const keyboard = (c.keyboard ?? []).map(k => `${k.keys.join(' + ')}: ${k.does}`)
|
|
106
|
+
const examples = (c.examples ?? []).map(e => `### ${e.caption}\n\n\`\`\`tsx\n${e.source}\n\`\`\``).join('\n\n')
|
|
107
|
+
return head + list('Keyboard', keyboard) + list('Do', c.dos) + list('Don\'t', c.donts) + (examples ? `\n\n## Examples\n\n${examples}` : '')
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
{
|
|
111
|
+
name: 'search',
|
|
112
|
+
description: 'Find components and tokens by what they are for, e.g. "confirm delete", "page background", "loading". Returns exact names to pass to get_component or to use as var(--name).',
|
|
113
|
+
inputSchema: {
|
|
114
|
+
type: 'object',
|
|
115
|
+
properties: {
|
|
116
|
+
query: { type: 'string' },
|
|
117
|
+
limit: { type: 'integer', minimum: 1, maximum: 50, description: 'Default 10.' },
|
|
118
|
+
},
|
|
119
|
+
required: ['query'],
|
|
120
|
+
additionalProperties: false,
|
|
121
|
+
},
|
|
122
|
+
run({ query, limit = 10 }) {
|
|
123
|
+
const terms = words(query)
|
|
124
|
+
if (terms.length === 0) throw new ToolError('query has no words to search for.')
|
|
125
|
+
// `props` is the prop descriptions: a word found there counts once, as a hint, so a long
|
|
126
|
+
// prop table does not outrank a component or token whose own summary is about the query.
|
|
127
|
+
const score = (title, body, props = '', propNames = '') => {
|
|
128
|
+
const t = words(title).join(' ')
|
|
129
|
+
const b = words(body)
|
|
130
|
+
const p = words(props)
|
|
131
|
+
const n = words(propNames)
|
|
132
|
+
const inMain = term => t.includes(term) || b.some(w => w.startsWith(term))
|
|
133
|
+
// Every word of the query in the name or summary beats one word found often: "page
|
|
134
|
+
// background" is the surface token, not PageHeader, whose name matches "page" alone.
|
|
135
|
+
const all = terms.length > 1 && terms.every(inMain) ? 5 : 0
|
|
136
|
+
return all + terms.reduce((sum, term) => sum + (t.includes(term) ? 3 : 0) + Math.min(b.filter(w => w.startsWith(term)).length, 3) + (inMain(term) ? 0 : n.some(w => w.startsWith(term)) ? 2 : p.some(w => w.startsWith(term)) ? 1 : 0), 0)
|
|
137
|
+
}
|
|
138
|
+
const hits = [
|
|
139
|
+
// The title holds every name on the page (ChatMessage and ResponseFeedback); the body adds
|
|
140
|
+
// the prop descriptions, where what a part does is written ("helpful", "thumbs up").
|
|
141
|
+
...components.map(c => {
|
|
142
|
+
const also = alsoExports(c)
|
|
143
|
+
const props = c.props.map(p => `${p.name} ${p.description ?? ''}`).join(' ')
|
|
144
|
+
const line = `${c.useWhen}${also.length ? ` Also on its page: ${also.join(', ')}.` : ''}`
|
|
145
|
+
return { kind: 'component', name: c.name, line, score: score(`${c.name} ${also.join(' ')}`, `${c.summary} ${c.useWhen}`, props, c.props.map(p => p.name).join(' ')) }
|
|
146
|
+
}),
|
|
147
|
+
// Primitives are not for screens (tokens.json's rule), so they never come up as an answer.
|
|
148
|
+
...tokens.tokens.filter(t => t.layer !== 'primitive').map(t => ({ kind: `${t.layer} token`, name: t.name, line: t.use ?? '', score: score(t.name, t.use ?? '') })),
|
|
149
|
+
]
|
|
150
|
+
.filter(hit => hit.score > 0)
|
|
151
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
152
|
+
.slice(0, limit)
|
|
153
|
+
if (hits.length === 0) return `Nothing matches "${query}". Try a broader word, or list_components / get_tokens.`
|
|
154
|
+
return hits.map(hit => `- ${hit.name} (${hit.kind}): ${hit.line}`).join('\n')
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
{
|
|
158
|
+
name: 'get_tokens',
|
|
159
|
+
description: 'Design tokens with their values (light and dark for themed colours) and when to use each. Screens use semantic and component tokens only, as var(--name) inside a Tailwind arbitrary value: text-[var(--color-text-primary)].',
|
|
160
|
+
inputSchema: {
|
|
161
|
+
type: 'object',
|
|
162
|
+
properties: {
|
|
163
|
+
layer: { type: 'string', enum: LAYERS, description: 'Only this layer. Default: every layer except primitive.' },
|
|
164
|
+
query: { type: 'string', description: 'Only tokens whose name or use contains this text.' },
|
|
165
|
+
},
|
|
166
|
+
additionalProperties: false,
|
|
167
|
+
},
|
|
168
|
+
run({ layer, query } = {}) {
|
|
169
|
+
if (layer !== undefined && !LAYERS.includes(layer)) throw new ToolError(`layer is one of ${LAYERS.join(', ')}.`)
|
|
170
|
+
const needle = query?.toLowerCase()
|
|
171
|
+
const rows = tokens.tokens
|
|
172
|
+
.filter(t => (layer ? t.layer === layer : t.layer !== 'primitive'))
|
|
173
|
+
.filter(t => !needle || `${t.name} ${t.use ?? ''}`.toLowerCase().includes(needle))
|
|
174
|
+
if (rows.length === 0) return 'No tokens match.'
|
|
175
|
+
const value = v => (typeof v === 'object' ? `dark ${v.dark}, light ${v.light}` : v)
|
|
176
|
+
return `${tokens.rule}\n\n${rows.map(t => `- ${t.name} (${t.layer}) = ${value(t.value)}${t.use ? `. ${t.use}` : ''}`).join('\n')}`
|
|
177
|
+
},
|
|
178
|
+
},
|
|
179
|
+
{
|
|
180
|
+
name: 'validate_code',
|
|
181
|
+
description: 'Checks code you wrote with Division DS components, before you say it is done: raw colours, Tailwind palette classes, token names that do not exist, two buttons with no gap, unknown props, prop values outside their union, "AI" in a button label and "answer"/"please"/"!" in assistant component labels. Props are checked only on elements imported from Division DS paths. Each finding says what to write instead. Same gates as `npx division-ds check`.',
|
|
182
|
+
inputSchema: {
|
|
183
|
+
type: 'object',
|
|
184
|
+
properties: {
|
|
185
|
+
code: { type: 'string', description: 'The whole file, imports included: props are matched through the imports.' },
|
|
186
|
+
filename: { type: 'string', description: 'Its name, e.g. "src/pages/Settings.tsx". The extension decides which gates run: a .css file gets the colour and token checks, and setting a real token (--color-surface-base: ...) is allowed there, since that is how a project themes the system. Default "Screen.tsx".' },
|
|
187
|
+
},
|
|
188
|
+
required: ['code'],
|
|
189
|
+
additionalProperties: false,
|
|
190
|
+
},
|
|
191
|
+
run({ code, filename = 'Screen.tsx' }) {
|
|
192
|
+
if (!/\.(tsx|jsx|ts|css)$/.test(filename)) throw new ToolError('filename ends in .tsx, .jsx, .ts or .css.')
|
|
193
|
+
const findings = checkText(String(code), filename, index, tokenNames)
|
|
194
|
+
if (findings.length === 0) {
|
|
195
|
+
return `No findings in ${filename}: colours, tokens, button spacing, props and AI copy are clean.\nNot checked: how the screen behaves. Run get_review_checklist, in a browser.`
|
|
196
|
+
}
|
|
197
|
+
return `${findings.length} finding(s) in ${filename}. Fix each, then validate again.\n\n${findings.map(f => `- [${f.gate}] line ${f.line}: ${f.found}\n Fix: ${f.fix}`).join('\n')}`
|
|
198
|
+
},
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
name: 'check_contrast',
|
|
202
|
+
description: 'The WCAG 2.2 AA contrast of a foreground on a background, in both themes. Each colour is a token name (--color-text-secondary) or a colour (#a1a1aa, rgb()). Translucent colours are composited over --color-surface-base. Use it before pairing two tokens the system does not already pair.',
|
|
203
|
+
inputSchema: {
|
|
204
|
+
type: 'object',
|
|
205
|
+
properties: {
|
|
206
|
+
foreground: { type: 'string' },
|
|
207
|
+
background: { type: 'string' },
|
|
208
|
+
use: { type: 'string', enum: ['normal', 'large', 'ui'], description: 'normal text (4.5:1), large text (3:1: 24px, or 18.66px bold), or a control boundary / graphic (3:1). Default normal.' },
|
|
209
|
+
},
|
|
210
|
+
required: ['foreground', 'background'],
|
|
211
|
+
additionalProperties: false,
|
|
212
|
+
},
|
|
213
|
+
run({ foreground, background, use = 'normal' }) {
|
|
214
|
+
const min = MINIMUMS[use]
|
|
215
|
+
if (min === undefined) throw new ToolError('use is one of normal, large, ui.')
|
|
216
|
+
const lines = ['dark', 'light'].map(theme => {
|
|
217
|
+
const fg = colour(foreground, theme)
|
|
218
|
+
const bg = colour(background, theme)
|
|
219
|
+
let ratio
|
|
220
|
+
try {
|
|
221
|
+
ratio = contrastRatio(fg, bg, tokenValue('--color-surface-base', theme))
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw new ToolError(error.message)
|
|
224
|
+
}
|
|
225
|
+
return `- ${theme}: ${ratio.toFixed(2)}:1 ${ratio >= min ? 'PASS' : 'FAIL'} (${fg} on ${bg})`
|
|
226
|
+
})
|
|
227
|
+
const failed = lines.some(line => line.includes('FAIL'))
|
|
228
|
+
return `${foreground} on ${background}, ${use} (needs ${min}:1):\n${lines.join('\n')}${failed ? '\n\nPick another pair: search for a text or border token meant for this surface.' : ''}`
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
{
|
|
232
|
+
name: 'get_add_command',
|
|
233
|
+
description: 'The shadcn command that installs components into a project, with their dependencies. Use when the project does not have the components yet.',
|
|
234
|
+
inputSchema: {
|
|
235
|
+
type: 'object',
|
|
236
|
+
properties: {
|
|
237
|
+
components: { type: 'array', items: { type: 'string' }, description: 'Names or slugs. Empty or "all" gives the command for every component.' },
|
|
238
|
+
},
|
|
239
|
+
additionalProperties: false,
|
|
240
|
+
},
|
|
241
|
+
run({ components: wanted = [] } = {}) {
|
|
242
|
+
if (wanted.length === 0 || (wanted.length === 1 && wanted[0] === 'all')) {
|
|
243
|
+
return `npx shadcn@latest add ${REGISTRY_BASE}/division-ds-all.json\n\nIt installs under src/division-ds/. Then mount one <Toaster> and one <TooltipProvider> at the root of the app.`
|
|
244
|
+
}
|
|
245
|
+
const urls = wanted.map(name => `${REGISTRY_BASE}/${(findComponent(name) ?? unknownComponent(name)).slug}.json`)
|
|
246
|
+
return `npx shadcn@latest add ${urls.join(' ')}\n\nIt installs under src/division-ds/, with the components these depend on.`
|
|
247
|
+
},
|
|
248
|
+
},
|
|
249
|
+
{
|
|
250
|
+
name: 'get_review_checklist',
|
|
251
|
+
description: 'The checklist to run over a screen before saying it is done: states, headings, focus, forms, 320px, both themes, motion. validate_code covers the code; this covers behaviour.',
|
|
252
|
+
inputSchema: { type: 'object', properties: {}, additionalProperties: false },
|
|
253
|
+
run() {
|
|
254
|
+
const checklist = context.readChecklist?.()
|
|
255
|
+
if (!checklist) throw new ToolError('The review checklist is not in this package. Read it at https://github.com/Victor-Ilioiu/division-ds/blob/main/skills/division-ds/commands/review.md')
|
|
256
|
+
return checklist
|
|
257
|
+
},
|
|
258
|
+
},
|
|
259
|
+
]
|
|
260
|
+
|
|
261
|
+
return tools.map(tool => ({ ...tool, run: args => tool.run(args ?? {}) + stamp }))
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** A mistake in the agent's arguments: reported to it as a tool error it can act on, not as a
|
|
265
|
+
* protocol failure. */
|
|
266
|
+
export class ToolError extends Error {}
|
|
@@ -28,6 +28,7 @@
|
|
|
28
28
|
// accepted; one the rest type names in its Omit<> is not. With a `...rest` row of a Radix
|
|
29
29
|
// component's props, any other prop is accepted (the Radix surface is not in the manifest).
|
|
30
30
|
import ts from 'typescript'
|
|
31
|
+
import { DOCS_SITE } from './agent-docs.mjs'
|
|
31
32
|
import { parseRowName, resolvePart } from './component-props.mjs'
|
|
32
33
|
|
|
33
34
|
/** Always accepted on any component: React's own props, and attributes every DOM wrapper takes. */
|
|
@@ -125,7 +126,7 @@ export function buildIndex(manifest) {
|
|
|
125
126
|
if (exportNames.length === 0) continue
|
|
126
127
|
const components = Object.fromEntries(exportNames.map(name => [name, true]))
|
|
127
128
|
const specs = Object.fromEntries(
|
|
128
|
-
exportNames.map(name => [name, { entry: entry.name, docs: entry.docs
|
|
129
|
+
exportNames.map(name => [name, { entry: entry.name, docs: entry.docs && `${DOCS_SITE}${entry.docs.replace(/^\//, '')}`, props: new Map(), rest: null }])
|
|
129
130
|
)
|
|
130
131
|
let unresolved = false
|
|
131
132
|
for (const row of entry.props ?? []) {
|
|
@@ -218,7 +219,7 @@ function literalValues(initializer) {
|
|
|
218
219
|
return collect(initializer.expression)
|
|
219
220
|
}
|
|
220
221
|
|
|
221
|
-
function scriptKind(path) {
|
|
222
|
+
export function scriptKind(path) {
|
|
222
223
|
if (/\.tsx$/.test(path)) return ts.ScriptKind.TSX
|
|
223
224
|
if (/\.jsx$/.test(path)) return ts.ScriptKind.JSX
|
|
224
225
|
if (/\.[cm]?js$/.test(path)) return ts.ScriptKind.JSX
|
|
@@ -12,8 +12,8 @@
|
|
|
12
12
|
// agents AGENTS.md Codex, Claude Code (via AGENTS.md), Copilot, Jules, ...
|
|
13
13
|
// cursor .cursor/rules/division-ds.mdc Cursor project rules; the whole file is ours
|
|
14
14
|
// gemini GEMINI.md Gemini CLI
|
|
15
|
-
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
|
16
|
-
import { dirname, join } from 'node:path'
|
|
15
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, rmdirSync, rmSync, statSync, writeFileSync } from 'node:fs'
|
|
16
|
+
import { dirname, join, relative } from 'node:path'
|
|
17
17
|
|
|
18
18
|
export const START = '<!-- division-ds:start -->'
|
|
19
19
|
export const END = '<!-- division-ds:end -->'
|
|
@@ -102,14 +102,52 @@ export function planRules({ targetDir, rules, only = TARGETS, remove = false })
|
|
|
102
102
|
})
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
|
|
106
|
-
|
|
105
|
+
// The skill installer (`division-ds skill`): the generated skills/division-ds/ folder, copied whole
|
|
106
|
+
// into the folders agents scan for skills. Checked 2026-10-26 against each agent's docs:
|
|
107
|
+
// agents .agents/skills/division-ds/ Codex (repo scope), Cursor (also reads .claude/skills)
|
|
108
|
+
// claude .claude/skills/division-ds/ Claude Code project skills
|
|
109
|
+
// The folder is ours whole, like the .mdc: a file in it that the skill no longer ships is deleted.
|
|
110
|
+
export const SKILL_TARGETS = ['agents', 'claude']
|
|
111
|
+
const SKILL_DIRS = { agents: '.agents/skills/division-ds', claude: '.claude/skills/division-ds' }
|
|
112
|
+
|
|
113
|
+
const filesUnder = dir =>
|
|
114
|
+
!existsSync(dir)
|
|
115
|
+
? []
|
|
116
|
+
: readdirSync(dir, { recursive: true }).map(String).filter(name => statSync(join(dir, name)).isFile()).map(name => name.split('\\').join('/')).sort()
|
|
117
|
+
|
|
118
|
+
/** Every file change to install (or `remove`) the skill in `sourceDir` into each target, in the
|
|
119
|
+
* same shape as planRules: `[{ target, path, action, before, after }]`. */
|
|
120
|
+
export function planSkill({ targetDir, sourceDir, only = SKILL_TARGETS, remove = false }) {
|
|
121
|
+
const source = filesUnder(sourceDir)
|
|
122
|
+
return only.flatMap(target => {
|
|
123
|
+
const dir = join(targetDir, SKILL_DIRS[target])
|
|
124
|
+
const names = [...new Set([...source, ...filesUnder(dir)])].sort()
|
|
125
|
+
return names.map(name => {
|
|
126
|
+
const path = join(dir, name)
|
|
127
|
+
const before = read(path)
|
|
128
|
+
const after = remove || !source.includes(name) ? null : readFileSync(join(sourceDir, name), 'utf8')
|
|
129
|
+
let action
|
|
130
|
+
if (before === null) action = after === null ? 'absent' : 'create'
|
|
131
|
+
else if (after === null) action = 'delete'
|
|
132
|
+
else action = after === before ? 'unchanged' : 'update'
|
|
133
|
+
return { target, path, action, before, after }
|
|
134
|
+
})
|
|
135
|
+
})
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Writes a plan to disk. A deletion also removes the folders it left empty, up to `stopAt`. */
|
|
139
|
+
export function applyPlan(plan, stopAt) {
|
|
107
140
|
for (const change of plan) {
|
|
108
141
|
if (change.action === 'create' || change.action === 'update') {
|
|
109
142
|
mkdirSync(dirname(change.path), { recursive: true })
|
|
110
143
|
writeFileSync(change.path, change.after)
|
|
111
144
|
} else if (change.action === 'delete') {
|
|
112
145
|
rmSync(change.path)
|
|
146
|
+
if (!stopAt) continue
|
|
147
|
+
for (let dir = dirname(change.path); relative(stopAt, dir) && !relative(stopAt, dir).startsWith('..'); dir = dirname(dir)) {
|
|
148
|
+
if (readdirSync(dir).length > 0) break
|
|
149
|
+
rmdirSync(dir)
|
|
150
|
+
}
|
|
113
151
|
}
|
|
114
152
|
}
|
|
115
153
|
}
|