@onexeor/lumo 0.0.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/LICENSE +21 -0
- package/README.md +47 -0
- package/package.json +50 -0
- package/skill/SKILL.md +344 -0
- package/src/commands/doctor.js +50 -0
- package/src/commands/init.js +99 -0
- package/src/commands/uninstall.js +51 -0
- package/src/index.js +59 -0
- package/src/lib/clients.js +81 -0
- package/src/lib/mcp.js +72 -0
- package/src/lib/python.js +94 -0
- package/src/lib/skill.js +64 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Viktor Savchik
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# lumo — installer
|
|
2
|
+
|
|
3
|
+
One-command installer for [Lumo](https://github.com/OneXeor/lumo): mobile
|
|
4
|
+
UI/UX design intelligence for AI coding assistants.
|
|
5
|
+
|
|
6
|
+
```bash
|
|
7
|
+
npx @onexeor/lumo init # interactive — picks your AI client
|
|
8
|
+
npx @onexeor/lumo init --ai claude # explicit target
|
|
9
|
+
npx @onexeor/lumo init --all # install everywhere supported
|
|
10
|
+
npx @onexeor/lumo init --ai claude --dev # install from a local git clone (contributors)
|
|
11
|
+
npx @onexeor/lumo init --no-mcp # skill only, skip MCP server registration
|
|
12
|
+
|
|
13
|
+
# After install the binary is `lumo` (the scoped package name is only
|
|
14
|
+
# needed to disambiguate during install):
|
|
15
|
+
lumo doctor # verify every Lumo piece is in place
|
|
16
|
+
lumo uninstall --ai claude # remove the skill (Python tools stay)
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Supported AI clients in v0.1:
|
|
20
|
+
|
|
21
|
+
- **Claude Code** — copies the skill into `~/.claude/skills/lumo/` and
|
|
22
|
+
registers the MCP server in `claude_desktop_config.json`
|
|
23
|
+
- **Cursor** — installs to `~/.cursor/rules/lumo/` and registers MCP in
|
|
24
|
+
`~/.cursor/mcp.json`
|
|
25
|
+
- **OpenAI Codex CLI** — installs to `~/.codex/skills/lumo/` and
|
|
26
|
+
registers MCP in `~/.codex/mcp.json`
|
|
27
|
+
- **generic** — prints install paths so you can wire it into anything
|
|
28
|
+
not listed above
|
|
29
|
+
|
|
30
|
+
Under the hood the installer:
|
|
31
|
+
|
|
32
|
+
1. Locates a Python 3.10+ interpreter and creates a Lumo-owned venv at
|
|
33
|
+
`~/.lumo/venv` so your system Python stays clean.
|
|
34
|
+
2. `pip install`s [`lumo-tools`](https://pypi.org/project/lumo-tools/) into
|
|
35
|
+
that venv. The CLIs (`lumo-wcag`, `lumo-theory`, `lumo-parity`,
|
|
36
|
+
`lumo-mcp`) become available at absolute paths the MCP configs point to.
|
|
37
|
+
3. Copies the `SKILL.md` bundle into the chosen client's skill directory.
|
|
38
|
+
4. Merges a `{ "mcpServers": { "lumo": { "command": "..." } } }` block
|
|
39
|
+
into the client's MCP config — non-destructive, backs up the file once
|
|
40
|
+
before the first write.
|
|
41
|
+
|
|
42
|
+
The installer never modifies anything outside `~/.lumo`, the chosen
|
|
43
|
+
client's skill directory, and the chosen client's MCP config file.
|
|
44
|
+
|
|
45
|
+
## License
|
|
46
|
+
|
|
47
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@onexeor/lumo",
|
|
3
|
+
"version": "0.0.1",
|
|
4
|
+
"description": "Install Lumo — mobile UI/UX design intelligence for AI coding assistants (Claude Code, Cursor, Codex, MCP clients).",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"lumo": "./src/index.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"skill",
|
|
12
|
+
"README.md",
|
|
13
|
+
"LICENSE"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"start": "node src/index.js",
|
|
20
|
+
"prepack": "node scripts/bundle-skill.js"
|
|
21
|
+
},
|
|
22
|
+
"keywords": [
|
|
23
|
+
"mobile",
|
|
24
|
+
"design",
|
|
25
|
+
"ui",
|
|
26
|
+
"ux",
|
|
27
|
+
"wcag",
|
|
28
|
+
"claude",
|
|
29
|
+
"cursor",
|
|
30
|
+
"codex",
|
|
31
|
+
"mcp",
|
|
32
|
+
"jetpack-compose",
|
|
33
|
+
"swiftui",
|
|
34
|
+
"ai-skill"
|
|
35
|
+
],
|
|
36
|
+
"author": "OneXeor",
|
|
37
|
+
"license": "MIT",
|
|
38
|
+
"repository": {
|
|
39
|
+
"type": "git",
|
|
40
|
+
"url": "https://github.com/OneXeor/lumo.git"
|
|
41
|
+
},
|
|
42
|
+
"engines": {
|
|
43
|
+
"node": ">=18"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"commander": "^12.1.0",
|
|
47
|
+
"kleur": "^4.1.5",
|
|
48
|
+
"prompts": "^2.4.2"
|
|
49
|
+
}
|
|
50
|
+
}
|
package/skill/SKILL.md
ADDED
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: lumo
|
|
3
|
+
description: "Mobile UI/UX design intelligence grounded in cognitive science (Fitts, Hick, Gestalt, Nielsen) and platform guidelines (Apple HIG, Material Design). Specialised for Jetpack Compose, Android XML, SwiftUI, and UIKit. Use when: \"WCAG\", \"contrast\", \"accessibility\", \"colour pair\", \"design audit\", \"mobile UI review\", \"Compose vs SwiftUI\", \"Fitts's law\", \"Hick's law\", \"touch target\", \"safe area\"."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Lumo
|
|
7
|
+
|
|
8
|
+
Mobile design intelligence for Claude Code. Lumo combines a curated rule set
|
|
9
|
+
with a small toolkit of deterministic Python tools — so checks that need real
|
|
10
|
+
math (WCAG luminance, OKLCH correction, AST diff, cross-platform parity)
|
|
11
|
+
return facts, not LLM guesses.
|
|
12
|
+
|
|
13
|
+
## When to Use
|
|
14
|
+
|
|
15
|
+
Use Lumo when the user request touches any of:
|
|
16
|
+
|
|
17
|
+
- Accessibility / WCAG contrast questions on a colour pair or palette
|
|
18
|
+
- Reviewing or auditing mobile UI code (Jetpack Compose, Android XML,
|
|
19
|
+
SwiftUI, UIKit)
|
|
20
|
+
- Comparing the same screen across iOS and Android for visual / behavioural
|
|
21
|
+
parity (padding, animation timing, touch target, typography scale)
|
|
22
|
+
- Applying cognitive-science rules to a layout: Fitts (target size × distance),
|
|
23
|
+
Hick (choice count → decision time), Gestalt grouping, Nielsen heuristics
|
|
24
|
+
- Choosing or validating colour tokens for a mobile design system
|
|
25
|
+
- Detecting platform-specific anti-patterns (e.g. emoji as system icons,
|
|
26
|
+
hardcoded hex in components, missing safe-area handling)
|
|
27
|
+
|
|
28
|
+
## When NOT to Use
|
|
29
|
+
|
|
30
|
+
Skip Lumo entirely for:
|
|
31
|
+
|
|
32
|
+
- Pure backend / API / database tasks
|
|
33
|
+
- Non-mobile web design (use a web-focused skill instead)
|
|
34
|
+
- Build / CI / dependency / version-bump tasks
|
|
35
|
+
- Performance work that doesn't touch UI rendering
|
|
36
|
+
- Tasks where the user has explicitly opted out of design feedback
|
|
37
|
+
|
|
38
|
+
Do not invoke Lumo "just in case" the request might be design-adjacent.
|
|
39
|
+
If the request doesn't match a trigger above and the user hasn't named a
|
|
40
|
+
mobile UI concern, stay out.
|
|
41
|
+
|
|
42
|
+
## Tools
|
|
43
|
+
|
|
44
|
+
Lumo ships with Python tools. Each tool has a single CLI entry-point with
|
|
45
|
+
deterministic output. Invoke via Bash. Tools live in `tools/lumo/<area>/cli.py`
|
|
46
|
+
and are exposed as console scripts after `pip install -e tools/`.
|
|
47
|
+
|
|
48
|
+
### `lumo-wcag` — WCAG contrast validator + OKLCH auto-correct
|
|
49
|
+
|
|
50
|
+
When to invoke:
|
|
51
|
+
|
|
52
|
+
- The user provides a foreground/background colour pair and asks whether it
|
|
53
|
+
is accessible.
|
|
54
|
+
- The user pastes a palette (multiple pairs) and asks to validate it.
|
|
55
|
+
- A design audit needs to check colour tokens against WCAG.
|
|
56
|
+
|
|
57
|
+
Commands:
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
# Check a single pair against WCAG (default: AA / normal text)
|
|
61
|
+
lumo-wcag check --fg "<hex>" --bg "<hex>" [--level AA|AAA] [--size normal|large]
|
|
62
|
+
|
|
63
|
+
# Auto-correct a failing pair by adjusting the foreground in OKLCH
|
|
64
|
+
# (preserves chroma + hue, so brand identity stays intact)
|
|
65
|
+
lumo-wcag fix --fg "<hex>" --bg "<hex>" [--level AA|AAA] [--size normal|large]
|
|
66
|
+
|
|
67
|
+
# Add --json to either command for machine-readable output
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Worked example — checking a Tailwind blue-500 button on white:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
$ lumo-wcag check --fg "#3B82F6" --bg "#FFFFFF" --level AA --size normal
|
|
74
|
+
FAIL #3B82F6 on #FFFFFF ratio=3.678:1 required=4.5:1 (AA, normal text)
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
Worked example — auto-correcting a sky-300 label on white:
|
|
78
|
+
|
|
79
|
+
```bash
|
|
80
|
+
$ lumo-wcag fix --fg "#7DD3FC" --bg "#FFFFFF" --level AA --size normal
|
|
81
|
+
FIXED #7DD3FC → #1B7BA1 on #FFFFFF
|
|
82
|
+
ratio 1.667:1 → 4.779:1 (required 4.5:1)
|
|
83
|
+
strategy=darken_fg iterations=14
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Exit codes: `0` pass / unchanged, `1` check failed, `2` correction unreachable.
|
|
87
|
+
|
|
88
|
+
### `lumo-theory` — cognitive-science layout checks
|
|
89
|
+
|
|
90
|
+
When to invoke:
|
|
91
|
+
|
|
92
|
+
- The user provides a screen layout (as coordinates, as code, or as a
|
|
93
|
+
screenshot you've described) and asks for a design review.
|
|
94
|
+
- An audit needs to check Fitts (target difficulty), Hick (choice overload),
|
|
95
|
+
Gestalt proximity, or thumb-reachability of primary actions.
|
|
96
|
+
|
|
97
|
+
What this tool **does not** do:
|
|
98
|
+
|
|
99
|
+
- It does not produce absolute Fitts MT or Hick RT in milliseconds.
|
|
100
|
+
Those depend on device-specific constants (a, b) with ±40 % variance
|
|
101
|
+
between studies. We return relative comparisons and discrete flags.
|
|
102
|
+
- It does not check Nielsen heuristics — those aren't reliably numeric.
|
|
103
|
+
See "Inline Rules → Nielsen heuristics" below for manual-review guidance.
|
|
104
|
+
- It does not invoke any LLM. If the layout JSON was estimated by a model,
|
|
105
|
+
declare that with `"source": "description-estimated"` so findings carry
|
|
106
|
+
the right confidence label.
|
|
107
|
+
|
|
108
|
+
Command:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
lumo-theory check --layout path/to/layout.json [--json]
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
Layout JSON schema:
|
|
115
|
+
|
|
116
|
+
```json
|
|
117
|
+
{
|
|
118
|
+
"screen": { "width": 411, "height": 891, "unit": "dp" },
|
|
119
|
+
"source": "measured | code-estimated | description-estimated",
|
|
120
|
+
"elements": [
|
|
121
|
+
{
|
|
122
|
+
"id": "btn_continue",
|
|
123
|
+
"role": "primary_action",
|
|
124
|
+
"x": 24, "y": 800, "w": 363, "h": 56,
|
|
125
|
+
"group": "form_actions",
|
|
126
|
+
"weight": "primary"
|
|
127
|
+
}
|
|
128
|
+
]
|
|
129
|
+
}
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
- `role` ∈ `primary_action | secondary_action | nav_item | tab |
|
|
133
|
+
list_item | input | icon_button | text | image | decorative`
|
|
134
|
+
- `weight` ∈ `primary | secondary | equal` (default `equal`)
|
|
135
|
+
- `group` is a free-form string used by Hick (equal-weight overload) and
|
|
136
|
+
Gestalt proximity.
|
|
137
|
+
- `source` reports honesty: `measured` means the coordinates came from a
|
|
138
|
+
real device (Espresso, XCUITest, Compose `onGloballyPositioned`,
|
|
139
|
+
SwiftUI `GeometryReader`); `code-estimated` from static code parsing;
|
|
140
|
+
`description-estimated` from a description or screenshot. The tool
|
|
141
|
+
propagates this value to every finding so the user can weigh confidence.
|
|
142
|
+
|
|
143
|
+
When you (the model) construct a layout from a screenshot or from Compose
|
|
144
|
+
/ SwiftUI source code, set `source` to the matching honest label. Do not
|
|
145
|
+
default to `measured` — that would falsely inflate confidence.
|
|
146
|
+
|
|
147
|
+
Worked example — a deliberately bad screen:
|
|
148
|
+
|
|
149
|
+
```bash
|
|
150
|
+
$ lumo-theory check --layout examples/bad.json
|
|
151
|
+
FOUND 3 findings (2 high, 1 medium)
|
|
152
|
+
source: measured
|
|
153
|
+
|
|
154
|
+
1. [HIGH ] fitts_undersized_target
|
|
155
|
+
elements: close
|
|
156
|
+
Element 'close' is 32dp on its shorter side, below the minimum tap
|
|
157
|
+
target (48dp).
|
|
158
|
+
→ Increase the touchable area to at least 48dp, either by growing
|
|
159
|
+
the element or by extending the hit area (Compose:
|
|
160
|
+
Modifier.minimumInteractiveComponentSize; SwiftUI: .contentShape;
|
|
161
|
+
UIKit: hitTest override).
|
|
162
|
+
metric: smaller_side=32.00, minimum=48.00
|
|
163
|
+
...
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Exit codes: `0` no findings, `1` findings reported.
|
|
167
|
+
|
|
168
|
+
### `lumo-parity` — cross-platform parity diff
|
|
169
|
+
|
|
170
|
+
When to invoke:
|
|
171
|
+
|
|
172
|
+
- The user has the same screen built on Android (Compose / XML) and iOS
|
|
173
|
+
(SwiftUI / UIKit) and wants to know where the two diverge.
|
|
174
|
+
- The user has a design system (tokens) and wants to verify both platforms
|
|
175
|
+
match it.
|
|
176
|
+
|
|
177
|
+
What this tool does:
|
|
178
|
+
|
|
179
|
+
- Compares two layout JSONs (same schema as `lumo-theory`), one per platform.
|
|
180
|
+
- Flags numeric mismatches in size and presence of named elements.
|
|
181
|
+
- Whitelists known legitimate platform divergences (Material 48dp vs Apple
|
|
182
|
+
HIG 44pt touch targets, Material bottom nav 80dp vs iOS Tab Bar 49pt) and
|
|
183
|
+
reports them as `info`, not as mismatches.
|
|
184
|
+
- Optionally validates both layouts against a shared `lumo.config.json` so
|
|
185
|
+
divergence from the design system is reported as a separate, higher-
|
|
186
|
+
severity finding.
|
|
187
|
+
|
|
188
|
+
What this tool **does not** do:
|
|
189
|
+
|
|
190
|
+
- It does not flag position (x, y) mismatches. Different screen widths and
|
|
191
|
+
safe-area insets legitimately shift coordinates; flagging those would be
|
|
192
|
+
noise. Size, presence, and design-system tokens are what matter for parity.
|
|
193
|
+
- It does not parse Compose or SwiftUI source code in v1. The model is
|
|
194
|
+
expected to translate code into layout JSON as preprocessing, then call
|
|
195
|
+
the tool. Set `source` to `code-estimated` in that case.
|
|
196
|
+
|
|
197
|
+
Common bug pattern this tool catches:
|
|
198
|
+
|
|
199
|
+
A junior dev writes `Modifier.padding(16.dp)` on Android but `.padding(48)`
|
|
200
|
+
on SwiftUI, believing iOS uses "3× because Retina". Both `dp` and `pt` are
|
|
201
|
+
density-independent and **equal in physical size on screen**. 16 ≠ 48.
|
|
202
|
+
`lumo-parity` will flag this immediately.
|
|
203
|
+
|
|
204
|
+
Command:
|
|
205
|
+
|
|
206
|
+
```bash
|
|
207
|
+
lumo-parity diff \
|
|
208
|
+
--android path/to/android.json \
|
|
209
|
+
--ios path/to/ios.json \
|
|
210
|
+
[--config path/to/lumo.config.json] \
|
|
211
|
+
[--json]
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
Optional `lumo.config.json` schema:
|
|
215
|
+
|
|
216
|
+
```json
|
|
217
|
+
{
|
|
218
|
+
"spacing": { "sm": 8, "md": 16, "lg": 24 },
|
|
219
|
+
"sizing": { "primary_button_height": 56 },
|
|
220
|
+
"colors": { "primary": "brand.primary", "surface": "brand.surface" }
|
|
221
|
+
}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Worked example — Android card with `padding(16.dp)` paired with SwiftUI
|
|
225
|
+
`.padding(48)`:
|
|
226
|
+
|
|
227
|
+
```bash
|
|
228
|
+
$ lumo-parity diff --android examples/parity_android.json --ios examples/parity_ios.json
|
|
229
|
+
FOUND 6 parity findings (1 high, 3 medium, 2 info)
|
|
230
|
+
...
|
|
231
|
+
2. [MEDIUM] height_mismatch
|
|
232
|
+
element: card_offer
|
|
233
|
+
android: 16.0 ios: 48.0
|
|
234
|
+
'card_offer' height differs between platforms: Android 16.0dp vs iOS 48.0pt.
|
|
235
|
+
dp and pt are both density-independent and should match.
|
|
236
|
+
...
|
|
237
|
+
5. [INFO ] platform_specific_default
|
|
238
|
+
element: nav_back
|
|
239
|
+
android: 48.0 ios: 44.0
|
|
240
|
+
'nav_back' width differs by design: 48.0dp / 44.0pt.
|
|
241
|
+
Minimum touch target: Material 48dp vs Apple HIG 44pt.
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Exit codes: `0` parity, `1` mismatches present.
|
|
245
|
+
|
|
246
|
+
## Decision Tree
|
|
247
|
+
|
|
248
|
+
| User request shape | Action |
|
|
249
|
+
|---|---|
|
|
250
|
+
| "Is `#ABC123` on `#FFFFFF` accessible?" | `lumo-wcag check` with that pair. |
|
|
251
|
+
| "Fix this colour pair." | `lumo-wcag fix`. Report both the corrected hex and the strategy. |
|
|
252
|
+
| "Audit this palette." | Run `lumo-wcag check` for every pair in the palette. Summarise as a table. |
|
|
253
|
+
| "Review this Compose / SwiftUI screen." | Build a layout JSON (set `source` honestly), then run `lumo-theory check`. Combine with inline rules below for things the tool doesn't cover (typography, animation, anti-patterns). |
|
|
254
|
+
| "Is this primary action reachable?" | `lumo-theory check` — the `reach_*` checks cover this. |
|
|
255
|
+
| "Are there too many choices on this screen?" | `lumo-theory check` — the `hick_overload` check covers this. |
|
|
256
|
+
| "Compare this iOS screen to its Android version." | Build layout JSONs for both platforms (set `source` honestly), then run `lumo-parity diff`. Pass `--config lumo.config.json` when the user has a shared design system. |
|
|
257
|
+
| "Does my SwiftUI match the design tokens?" | `lumo-parity diff` with `--config`. Even a single-platform check benefits from design-system validation. |
|
|
258
|
+
|
|
259
|
+
## Output Format Contract
|
|
260
|
+
|
|
261
|
+
When a tool returns results, format the final answer for the user as:
|
|
262
|
+
|
|
263
|
+
1. **One-line verdict** — `PASS`, `FAIL: <count> issues`, or `FIXED: <change>`.
|
|
264
|
+
2. **Issues table** when there are multiple findings, with these columns and
|
|
265
|
+
nothing else:
|
|
266
|
+
|
|
267
|
+
| # | Where | Issue | Severity | Fix |
|
|
268
|
+
|---|---|---|---|---|
|
|
269
|
+
|
|
270
|
+
3. **Tool output** as a fenced block, verbatim. Do not paraphrase ratios or
|
|
271
|
+
hex values — they are facts and must round-trip exactly.
|
|
272
|
+
4. **Suggested next step** in one sentence, if and only if there is one.
|
|
273
|
+
|
|
274
|
+
Do not add closing summaries, emojis, or restatements of the user's request.
|
|
275
|
+
|
|
276
|
+
## Inline Rules (apply when no tool covers the case)
|
|
277
|
+
|
|
278
|
+
These are stable rules that the model is expected to apply directly. They
|
|
279
|
+
duplicate what `lumo-wcag` and the Phase 2 tools will eventually automate —
|
|
280
|
+
keep them here until the tool exists.
|
|
281
|
+
|
|
282
|
+
### Touch targets
|
|
283
|
+
|
|
284
|
+
- iOS minimum tap target: **44 × 44 pt**. (Apple HIG, *Designing for iOS*.)
|
|
285
|
+
- Android minimum tap target: **48 × 48 dp**. (Material Design *Accessibility*.)
|
|
286
|
+
- Minimum gap between adjacent tap targets: **8 dp / 8 pt**.
|
|
287
|
+
- Below-minimum icons must extend their hit area (Compose `Modifier.minimumInteractiveComponentSize()`, SwiftUI `.contentShape(Rectangle())` with padding, UIKit `hitTest` override).
|
|
288
|
+
|
|
289
|
+
### Typography baseline
|
|
290
|
+
|
|
291
|
+
- Mobile body text: **≥ 16 sp / 16 pt**. Smaller triggers iOS auto-zoom and is below WCAG-recommended legibility.
|
|
292
|
+
- Line height for body: **1.4 – 1.6**.
|
|
293
|
+
- Always honour the system text-scale (Compose: `TextUnit.sp` not `dp`; SwiftUI: dynamic type styles, not fixed point sizes).
|
|
294
|
+
|
|
295
|
+
### Animation timing
|
|
296
|
+
|
|
297
|
+
- Micro-interactions: **150 – 300 ms**.
|
|
298
|
+
- Complex transitions: **≤ 400 ms**.
|
|
299
|
+
- Exits roughly 60–70 % of enter duration.
|
|
300
|
+
- Animate `transform` / `opacity` only — never `width`, `height`, `top`, `left`.
|
|
301
|
+
|
|
302
|
+
### Safe areas
|
|
303
|
+
|
|
304
|
+
- Compose: `Modifier.safeDrawingPadding()` / `WindowInsets.safeDrawing`.
|
|
305
|
+
- SwiftUI: `.safeAreaInset(edge:)` / `.ignoresSafeArea()` deliberately.
|
|
306
|
+
- UIKit: `safeAreaLayoutGuide`, not view bounds.
|
|
307
|
+
- XML: `android:fitsSystemWindows="true"` + insets handling, not hardcoded padding.
|
|
308
|
+
|
|
309
|
+
### Common anti-patterns
|
|
310
|
+
|
|
311
|
+
| Avoid | Prefer | Why |
|
|
312
|
+
|---|---|---|
|
|
313
|
+
| Emoji as system icons (`🏠`, `⚙️`) | SVG / vector icon sets (Lucide, Material Symbols, SF Symbols) | Emoji rendering is font-dependent, inconsistent across platforms, untokenisable. |
|
|
314
|
+
| Hardcoded hex inside components | Semantic tokens (`MaterialTheme.colorScheme.primary`, `Color("AccentColor")`) | Tokens survive theme switches and dark-mode auditing. |
|
|
315
|
+
| Placeholder-only labels | Visible `Text` / `Label` above the field | Placeholder disappears on focus and breaks accessibility. |
|
|
316
|
+
| Same components dressed differently per screen | Single styled component reused | Drift kills perceived polish. |
|
|
317
|
+
| `if Platform.isIOS` style branching scattered across UI code | Platform-specific files / extensions at the boundary | Prevents accidental "iOS-only" features creeping into Android. |
|
|
318
|
+
| Animating `width` / `height` to expand a card | Animate `scale` / `transform`, layout with `AnimatedContent` (Compose) or `matchedGeometryEffect` (SwiftUI) | The former triggers layout reflow each frame — janky on mid-range Android. |
|
|
319
|
+
| Hardcoded `padding(16.dp)` everywhere | A `Spacing` token scale (4 / 8 / 12 / 16 / 24 / 32) | Scale rhythm is a baseline polish signal. |
|
|
320
|
+
|
|
321
|
+
## Self-correction Loops
|
|
322
|
+
|
|
323
|
+
Apply these in order before answering:
|
|
324
|
+
|
|
325
|
+
1. **No tool needed?** If the request is conversational ("what's WCAG?"),
|
|
326
|
+
answer directly. Don't invoke `lumo-wcag` for theory questions.
|
|
327
|
+
2. **Tool error?** If `lumo-wcag` returns a non-zero exit beyond the
|
|
328
|
+
documented `1` (failed check) or `2` (correction unreachable), surface
|
|
329
|
+
the stderr verbatim and stop. Do not retry blindly.
|
|
330
|
+
3. **Zero findings?** Say "no WCAG issues at AA / normal for the pairs you
|
|
331
|
+
provided" — do not invent a finding to look useful.
|
|
332
|
+
4. **Many findings (> 10)?** Show the top issues by severity (CRITICAL →
|
|
333
|
+
HIGH → MEDIUM → LOW), then a one-line "+N more" pointer. Do not dump.
|
|
334
|
+
5. **Ambiguity?** If the user provided a single colour without saying which
|
|
335
|
+
is foreground and which is background, ask once. Do not guess.
|
|
336
|
+
|
|
337
|
+
## What Lumo Does NOT Do
|
|
338
|
+
|
|
339
|
+
- Lumo does not generate screens, mockups, or boilerplate.
|
|
340
|
+
- Lumo does not replace a designer's judgement on hierarchy, brand, or copy.
|
|
341
|
+
- Lumo does not call any network service. All tools run locally.
|
|
342
|
+
- Lumo does not store data anywhere outside the user's working directory.
|
|
343
|
+
|
|
344
|
+
If the user asks Lumo to do something on this list, say so and stop.
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
|
|
3
|
+
import kleur from "kleur";
|
|
4
|
+
|
|
5
|
+
import { CLIENTS } from "../lib/clients.js";
|
|
6
|
+
import { LUMO_HOME, listInstalledBinaries, venvBinary } from "../lib/python.js";
|
|
7
|
+
|
|
8
|
+
function row(ok, label, detail = "") {
|
|
9
|
+
const icon = ok ? kleur.green("✓") : kleur.red("✗");
|
|
10
|
+
const text = ok ? kleur.white(label) : kleur.yellow(label);
|
|
11
|
+
console.log(` ${icon} ${text}${detail ? kleur.dim(" " + detail) : ""}`);
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export async function doctorCommand() {
|
|
15
|
+
console.log(kleur.bold().cyan("\n• lumo doctor\n"));
|
|
16
|
+
|
|
17
|
+
console.log(kleur.bold("Python tools"));
|
|
18
|
+
row(fs.existsSync(LUMO_HOME), `Lumo home`, LUMO_HOME);
|
|
19
|
+
row(fs.existsSync(venvBinary("python")), `venv Python`, venvBinary("python"));
|
|
20
|
+
for (const bin of listInstalledBinaries()) {
|
|
21
|
+
row(bin.exists, bin.name, bin.path);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log(kleur.bold("Client integrations"));
|
|
26
|
+
for (const client of CLIENTS) {
|
|
27
|
+
if (!client.skillDir) continue;
|
|
28
|
+
const skillPresent = fs.existsSync(client.skillDir);
|
|
29
|
+
row(skillPresent, `${client.label} skill`, client.skillDir);
|
|
30
|
+
|
|
31
|
+
if (client.mcpConfigPath) {
|
|
32
|
+
if (!fs.existsSync(client.mcpConfigPath)) {
|
|
33
|
+
row(false, `${client.label} MCP config`, `not found at ${client.mcpConfigPath}`);
|
|
34
|
+
continue;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const raw = fs.readFileSync(client.mcpConfigPath, "utf8");
|
|
38
|
+
const data = JSON.parse(raw);
|
|
39
|
+
const hasLumo = Boolean(data[client.mcpConfigKey]?.lumo);
|
|
40
|
+
row(hasLumo, `${client.label} MCP registered`, client.mcpConfigPath);
|
|
41
|
+
} catch (err) {
|
|
42
|
+
row(false, `${client.label} MCP config unparseable`, err.message);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
console.log("");
|
|
48
|
+
console.log(kleur.dim("Run `lumo init` to install or repair any missing pieces."));
|
|
49
|
+
console.log("");
|
|
50
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { fileURLToPath } from "node:url";
|
|
4
|
+
|
|
5
|
+
import kleur from "kleur";
|
|
6
|
+
import prompts from "prompts";
|
|
7
|
+
|
|
8
|
+
import { CLIENTS, getClient } from "../lib/clients.js";
|
|
9
|
+
import { installLumoTools, listInstalledBinaries } from "../lib/python.js";
|
|
10
|
+
import { findSkillSource, installSkill } from "../lib/skill.js";
|
|
11
|
+
import { registerMcp } from "../lib/mcp.js";
|
|
12
|
+
|
|
13
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
14
|
+
const __dirname = path.dirname(__filename);
|
|
15
|
+
|
|
16
|
+
/** Resolve --dev source: the repo root above installer/. */
|
|
17
|
+
function devSource() {
|
|
18
|
+
return path.resolve(__dirname, "..", "..", "..", "tools");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
async function pickClient(supplied, allFlag) {
|
|
22
|
+
if (allFlag) return CLIENTS.filter((c) => c.id !== "generic");
|
|
23
|
+
if (supplied) return [getClient(supplied)];
|
|
24
|
+
|
|
25
|
+
const { ai } = await prompts({
|
|
26
|
+
type: "select",
|
|
27
|
+
name: "ai",
|
|
28
|
+
message: "Which AI client are you installing Lumo for?",
|
|
29
|
+
choices: CLIENTS.map((c) => ({ title: c.label, value: c.id })),
|
|
30
|
+
initial: 0,
|
|
31
|
+
});
|
|
32
|
+
if (!ai) {
|
|
33
|
+
throw new Error("No client selected.");
|
|
34
|
+
}
|
|
35
|
+
return [getClient(ai)];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function initCommand(opts) {
|
|
39
|
+
console.log(kleur.bold().cyan("\n• Lumo installer\n"));
|
|
40
|
+
|
|
41
|
+
const targets = await pickClient(opts.ai, opts.all);
|
|
42
|
+
|
|
43
|
+
console.log(kleur.dim("→ installing Python tools (lumo-tools) into ~/.lumo/venv ..."));
|
|
44
|
+
try {
|
|
45
|
+
if (opts.dev) {
|
|
46
|
+
const dev = devSource();
|
|
47
|
+
console.log(kleur.dim(` using --dev source ${dev}`));
|
|
48
|
+
await installLumoTools({ source: dev });
|
|
49
|
+
} else {
|
|
50
|
+
await installLumoTools();
|
|
51
|
+
}
|
|
52
|
+
} catch (err) {
|
|
53
|
+
throw new Error(`pip install failed: ${err.stderr || err.message || err}`);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const bins = listInstalledBinaries();
|
|
57
|
+
const missing = bins.filter((b) => !b.exists);
|
|
58
|
+
if (missing.length > 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
"Some Lumo CLIs did not install:\n " +
|
|
61
|
+
missing.map((b) => `${b.name} (expected at ${b.path})`).join("\n ")
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
console.log(kleur.green(`✓ Python tools installed`));
|
|
65
|
+
bins.forEach((b) => console.log(kleur.dim(` ${b.name} ${b.path}`)));
|
|
66
|
+
|
|
67
|
+
const skillSource = findSkillSource();
|
|
68
|
+
|
|
69
|
+
for (const client of targets) {
|
|
70
|
+
console.log(kleur.bold(`\n→ ${client.label}`));
|
|
71
|
+
if (!client.skillDir) {
|
|
72
|
+
console.log(kleur.dim(" generic mode — nothing to copy automatically."));
|
|
73
|
+
console.log(kleur.dim(" Skill bundle is at: ") + skillSource);
|
|
74
|
+
console.log(kleur.dim(" MCP server command: ") + listInstalledBinaries()[3].path);
|
|
75
|
+
continue;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
installSkill(client.skillDir);
|
|
79
|
+
console.log(kleur.green(` ✓ skill copied to ${client.skillDir}`));
|
|
80
|
+
|
|
81
|
+
if (opts.mcp !== false && client.mcpConfigPath && client.mcpConfigKey) {
|
|
82
|
+
try {
|
|
83
|
+
const { configPath, command } = registerMcp(client.mcpConfigPath, client.mcpConfigKey);
|
|
84
|
+
console.log(kleur.green(` ✓ MCP registered in ${configPath}`));
|
|
85
|
+
console.log(kleur.dim(` command: ${command}`));
|
|
86
|
+
} catch (err) {
|
|
87
|
+
console.log(
|
|
88
|
+
kleur.yellow(` ! MCP registration skipped: ${err.message}`)
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
} else if (opts.mcp === false) {
|
|
92
|
+
console.log(kleur.dim(" MCP registration skipped (--no-mcp)."));
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
console.log(kleur.bold().green("\n✓ Lumo installation complete.\n"));
|
|
97
|
+
console.log(kleur.dim("Next: open your AI client and ask it to use the Lumo skill."));
|
|
98
|
+
console.log(kleur.dim("Verify any time with: lumo doctor\n"));
|
|
99
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import kleur from "kleur";
|
|
2
|
+
import prompts from "prompts";
|
|
3
|
+
|
|
4
|
+
import { CLIENTS, getClient } from "../lib/clients.js";
|
|
5
|
+
import { unregisterMcp } from "../lib/mcp.js";
|
|
6
|
+
import { removeSkill } from "../lib/skill.js";
|
|
7
|
+
|
|
8
|
+
async function pickClient(supplied, allFlag) {
|
|
9
|
+
if (allFlag) return CLIENTS.filter((c) => c.id !== "generic");
|
|
10
|
+
if (supplied) return [getClient(supplied)];
|
|
11
|
+
|
|
12
|
+
const { ai } = await prompts({
|
|
13
|
+
type: "select",
|
|
14
|
+
name: "ai",
|
|
15
|
+
message: "Remove Lumo from which client?",
|
|
16
|
+
choices: CLIENTS.filter((c) => c.skillDir).map((c) => ({ title: c.label, value: c.id })),
|
|
17
|
+
initial: 0,
|
|
18
|
+
});
|
|
19
|
+
if (!ai) throw new Error("No client selected.");
|
|
20
|
+
return [getClient(ai)];
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export async function uninstallCommand(opts) {
|
|
24
|
+
console.log(kleur.bold().cyan("\n• Lumo uninstaller\n"));
|
|
25
|
+
|
|
26
|
+
const targets = await pickClient(opts.ai, opts.all);
|
|
27
|
+
|
|
28
|
+
for (const client of targets) {
|
|
29
|
+
if (!client.skillDir) continue;
|
|
30
|
+
console.log(kleur.bold(`\n→ ${client.label}`));
|
|
31
|
+
|
|
32
|
+
const removed = removeSkill(client.skillDir);
|
|
33
|
+
if (removed) {
|
|
34
|
+
console.log(kleur.green(` ✓ skill removed from ${client.skillDir}`));
|
|
35
|
+
} else {
|
|
36
|
+
console.log(kleur.dim(` skill not present (${client.skillDir})`));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (client.mcpConfigPath && client.mcpConfigKey) {
|
|
40
|
+
const unreg = unregisterMcp(client.mcpConfigPath, client.mcpConfigKey);
|
|
41
|
+
if (unreg) {
|
|
42
|
+
console.log(kleur.green(` ✓ MCP entry removed from ${client.mcpConfigPath}`));
|
|
43
|
+
} else {
|
|
44
|
+
console.log(kleur.dim(` no MCP entry to remove`));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
console.log(kleur.dim("\nPython tools (~/.lumo/venv) left intact."));
|
|
50
|
+
console.log(kleur.dim("Remove them manually with: rm -rf ~/.lumo\n"));
|
|
51
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* lumo — mobile UI/UX design intelligence installer.
|
|
4
|
+
*
|
|
5
|
+
* Subcommands:
|
|
6
|
+
* init [--ai <client>] [--all] Install the Lumo skill (+ optional MCP) into an AI client.
|
|
7
|
+
* doctor Verify Python tools + skill installation.
|
|
8
|
+
* uninstall [--ai <client>] Remove the Lumo skill (Python tools left intact).
|
|
9
|
+
*
|
|
10
|
+
* Supported AI clients in v0.1: claude, cursor, codex, generic.
|
|
11
|
+
* Other clients can still consume Lumo via `npx skills add OneXeor/lumo`
|
|
12
|
+
* or by pointing their MCP config at `lumo-mcp`.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { Command } from "commander";
|
|
16
|
+
import kleur from "kleur";
|
|
17
|
+
|
|
18
|
+
import { initCommand } from "./commands/init.js";
|
|
19
|
+
import { doctorCommand } from "./commands/doctor.js";
|
|
20
|
+
import { uninstallCommand } from "./commands/uninstall.js";
|
|
21
|
+
|
|
22
|
+
const program = new Command();
|
|
23
|
+
|
|
24
|
+
program
|
|
25
|
+
.name("lumo")
|
|
26
|
+
.description(
|
|
27
|
+
"Mobile UI/UX design intelligence — WCAG / parity / cognitive-science checks " +
|
|
28
|
+
"for Jetpack Compose, Android XML, SwiftUI, UIKit."
|
|
29
|
+
)
|
|
30
|
+
.version("0.0.1");
|
|
31
|
+
|
|
32
|
+
program
|
|
33
|
+
.command("init")
|
|
34
|
+
.description("Install the Lumo skill into an AI coding assistant.")
|
|
35
|
+
.option(
|
|
36
|
+
"-a, --ai <client>",
|
|
37
|
+
"Target client: claude | cursor | codex | generic (asks interactively if omitted)"
|
|
38
|
+
)
|
|
39
|
+
.option("--all", "Install into every supported client at once.")
|
|
40
|
+
.option("--no-mcp", "Skip registering the MCP server (skill-only install).")
|
|
41
|
+
.option("--dev", "Install from the current git clone instead of pip (for contributors).")
|
|
42
|
+
.action(initCommand);
|
|
43
|
+
|
|
44
|
+
program
|
|
45
|
+
.command("doctor")
|
|
46
|
+
.description("Verify Python tools, MCP server, and skill installation paths.")
|
|
47
|
+
.action(doctorCommand);
|
|
48
|
+
|
|
49
|
+
program
|
|
50
|
+
.command("uninstall")
|
|
51
|
+
.description("Remove the Lumo skill from an AI client (Python tools left intact).")
|
|
52
|
+
.option("-a, --ai <client>", "Which client to remove from (asks interactively if omitted)")
|
|
53
|
+
.option("--all", "Remove from every supported client at once.")
|
|
54
|
+
.action(uninstallCommand);
|
|
55
|
+
|
|
56
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
57
|
+
console.error(kleur.red(`\n✗ ${err.message ?? err}`));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
});
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supported AI client registry.
|
|
3
|
+
*
|
|
4
|
+
* Each entry tells the installer:
|
|
5
|
+
* - where to copy the SKILL.md (skillDir, optional)
|
|
6
|
+
* - where to register the MCP server (mcpConfigPath + mcpConfigKey, optional)
|
|
7
|
+
* - how to detect whether the client is installed (detectPaths)
|
|
8
|
+
*
|
|
9
|
+
* Adding a new client = adding one entry here. The init / uninstall / doctor
|
|
10
|
+
* commands all read from this registry.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
|
|
16
|
+
const HOME = os.homedir();
|
|
17
|
+
|
|
18
|
+
/** @typedef {{
|
|
19
|
+
* id: string,
|
|
20
|
+
* label: string,
|
|
21
|
+
* skillDir?: string,
|
|
22
|
+
* mcpConfigPath?: string,
|
|
23
|
+
* mcpConfigKey?: string,
|
|
24
|
+
* detectPaths: string[],
|
|
25
|
+
* }} ClientSpec
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/** @type {ClientSpec[]} */
|
|
29
|
+
export const CLIENTS = [
|
|
30
|
+
{
|
|
31
|
+
id: "claude",
|
|
32
|
+
label: "Claude Code",
|
|
33
|
+
// Claude Code reads skills from ~/.claude/skills/<name>/SKILL.md
|
|
34
|
+
skillDir: path.join(HOME, ".claude", "skills", "lumo"),
|
|
35
|
+
// Claude Desktop / Claude Code share a config file for MCP servers.
|
|
36
|
+
// On macOS the canonical path is ~/Library/Application Support/Claude/claude_desktop_config.json
|
|
37
|
+
// We fall back to ~/.claude/claude_desktop_config.json if the Library path doesn't exist.
|
|
38
|
+
mcpConfigPath:
|
|
39
|
+
process.platform === "darwin"
|
|
40
|
+
? path.join(HOME, "Library", "Application Support", "Claude", "claude_desktop_config.json")
|
|
41
|
+
: path.join(HOME, ".claude", "claude_desktop_config.json"),
|
|
42
|
+
mcpConfigKey: "mcpServers",
|
|
43
|
+
detectPaths: [path.join(HOME, ".claude"), path.join(HOME, "Library", "Application Support", "Claude")],
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
id: "cursor",
|
|
47
|
+
label: "Cursor",
|
|
48
|
+
// Cursor reads project rules from .cursorrules and global rules from
|
|
49
|
+
// ~/.cursor/rules/. For now Lumo writes a small pointer rule that
|
|
50
|
+
// delegates the heavy lifting to the MCP server.
|
|
51
|
+
skillDir: path.join(HOME, ".cursor", "rules", "lumo"),
|
|
52
|
+
mcpConfigPath: path.join(HOME, ".cursor", "mcp.json"),
|
|
53
|
+
mcpConfigKey: "mcpServers",
|
|
54
|
+
detectPaths: [path.join(HOME, ".cursor")],
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "codex",
|
|
58
|
+
label: "OpenAI Codex CLI",
|
|
59
|
+
// Codex CLI looks for skills in ~/.codex/skills/ when present.
|
|
60
|
+
skillDir: path.join(HOME, ".codex", "skills", "lumo"),
|
|
61
|
+
mcpConfigPath: path.join(HOME, ".codex", "mcp.json"),
|
|
62
|
+
mcpConfigKey: "mcpServers",
|
|
63
|
+
detectPaths: [path.join(HOME, ".codex")],
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
id: "generic",
|
|
67
|
+
label: "Generic / manual (just print install paths)",
|
|
68
|
+
// No skillDir or mcpConfigPath — generic mode only prints guidance.
|
|
69
|
+
detectPaths: [],
|
|
70
|
+
},
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
export function getClient(id) {
|
|
74
|
+
const client = CLIENTS.find((c) => c.id === id);
|
|
75
|
+
if (!client) {
|
|
76
|
+
throw new Error(
|
|
77
|
+
`Unknown client: ${id}. Supported: ${CLIENTS.map((c) => c.id).join(", ")}`
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
return client;
|
|
81
|
+
}
|
package/src/lib/mcp.js
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MCP server registration in a client's JSON config.
|
|
3
|
+
*
|
|
4
|
+
* All supported MCP clients (Claude, Cursor, Codex, etc) use roughly the
|
|
5
|
+
* same shape:
|
|
6
|
+
*
|
|
7
|
+
* {
|
|
8
|
+
* "mcpServers": {
|
|
9
|
+
* "lumo": { "command": "/abs/path/to/lumo-mcp", "args": [] }
|
|
10
|
+
* }
|
|
11
|
+
* }
|
|
12
|
+
*
|
|
13
|
+
* We:
|
|
14
|
+
* - create the file if missing
|
|
15
|
+
* - merge into an existing object without clobbering other servers
|
|
16
|
+
* - keep a backup copy as <file>.lumo.bak the first time we write
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import fs from "node:fs";
|
|
20
|
+
import path from "node:path";
|
|
21
|
+
|
|
22
|
+
import { venvBinary } from "./python.js";
|
|
23
|
+
|
|
24
|
+
const SERVER_KEY = "lumo";
|
|
25
|
+
|
|
26
|
+
function loadJsonOrEmpty(filePath) {
|
|
27
|
+
if (!fs.existsSync(filePath)) return {};
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
30
|
+
} catch (err) {
|
|
31
|
+
throw new Error(
|
|
32
|
+
`${filePath} exists but is not valid JSON. Fix it manually before re-running lumo init.\n parse error: ${err.message}`
|
|
33
|
+
);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function backupOnce(filePath) {
|
|
38
|
+
const backup = `${filePath}.lumo.bak`;
|
|
39
|
+
if (fs.existsSync(filePath) && !fs.existsSync(backup)) {
|
|
40
|
+
fs.copyFileSync(filePath, backup);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Register lumo-mcp under the client's mcpServers key. */
|
|
45
|
+
export function registerMcp(configPath, mcpKey) {
|
|
46
|
+
const lumoMcp = venvBinary("lumo-mcp");
|
|
47
|
+
|
|
48
|
+
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
49
|
+
backupOnce(configPath);
|
|
50
|
+
|
|
51
|
+
const config = loadJsonOrEmpty(configPath);
|
|
52
|
+
config[mcpKey] = config[mcpKey] || {};
|
|
53
|
+
config[mcpKey][SERVER_KEY] = {
|
|
54
|
+
command: lumoMcp,
|
|
55
|
+
args: [],
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
59
|
+
return { configPath, command: lumoMcp };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remove the `lumo` entry from the client's mcpServers, if present. */
|
|
63
|
+
export function unregisterMcp(configPath, mcpKey) {
|
|
64
|
+
if (!fs.existsSync(configPath)) return false;
|
|
65
|
+
const config = loadJsonOrEmpty(configPath);
|
|
66
|
+
if (!config[mcpKey] || !config[mcpKey][SERVER_KEY]) return false;
|
|
67
|
+
delete config[mcpKey][SERVER_KEY];
|
|
68
|
+
// Drop the mcp key entirely if it's now empty.
|
|
69
|
+
if (Object.keys(config[mcpKey]).length === 0) delete config[mcpKey];
|
|
70
|
+
fs.writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
|
|
71
|
+
return true;
|
|
72
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Python detection and lumo-tools install helpers.
|
|
3
|
+
*
|
|
4
|
+
* Strategy:
|
|
5
|
+
* - Locate a Python ≥3.10 interpreter (python3, python, py -3 on Windows).
|
|
6
|
+
* - Maintain a Lumo-owned venv at ~/.lumo/venv so user system Python stays clean.
|
|
7
|
+
* - Install lumo-tools into that venv via pip — from PyPI by default,
|
|
8
|
+
* from a local git path when --dev was passed.
|
|
9
|
+
* - Expose absolute paths to each console script so SKILL.md and MCP configs
|
|
10
|
+
* can reference them without depending on PATH.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { execFile, spawnSync } from "node:child_process";
|
|
14
|
+
import fs from "node:fs";
|
|
15
|
+
import os from "node:os";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { promisify } from "node:util";
|
|
18
|
+
|
|
19
|
+
const execFileP = promisify(execFile);
|
|
20
|
+
|
|
21
|
+
export const LUMO_HOME = path.join(os.homedir(), ".lumo");
|
|
22
|
+
export const VENV_DIR = path.join(LUMO_HOME, "venv");
|
|
23
|
+
const BIN_DIR = process.platform === "win32" ? "Scripts" : "bin";
|
|
24
|
+
|
|
25
|
+
export function venvBinary(name) {
|
|
26
|
+
const ext = process.platform === "win32" ? ".exe" : "";
|
|
27
|
+
return path.join(VENV_DIR, BIN_DIR, `${name}${ext}`);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Returns the first usable Python interpreter or throws with install hint. */
|
|
31
|
+
export function findPython() {
|
|
32
|
+
const candidates =
|
|
33
|
+
process.platform === "win32"
|
|
34
|
+
? [["py", "-3"], ["python3"], ["python"]]
|
|
35
|
+
: [["python3"], ["python"]];
|
|
36
|
+
|
|
37
|
+
for (const [cmd, ...args] of candidates) {
|
|
38
|
+
const result = spawnSync(cmd, [...args, "--version"], { encoding: "utf8" });
|
|
39
|
+
if (result.status !== 0) continue;
|
|
40
|
+
const out = (result.stdout || result.stderr || "").trim();
|
|
41
|
+
const match = out.match(/Python (\d+)\.(\d+)/);
|
|
42
|
+
if (!match) continue;
|
|
43
|
+
const major = Number(match[1]);
|
|
44
|
+
const minor = Number(match[2]);
|
|
45
|
+
if (major < 3 || (major === 3 && minor < 10)) continue;
|
|
46
|
+
return { cmd, args, version: `${major}.${minor}` };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
throw new Error(
|
|
50
|
+
"Python 3.10+ not found.\n" +
|
|
51
|
+
" macOS: brew install python@3.12\n" +
|
|
52
|
+
" Ubuntu: sudo apt install python3.12 python3.12-venv\n" +
|
|
53
|
+
" Windows: winget install Python.Python.3.12"
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Create ~/.lumo/venv if it doesn't already exist. */
|
|
58
|
+
export async function ensureVenv() {
|
|
59
|
+
fs.mkdirSync(LUMO_HOME, { recursive: true });
|
|
60
|
+
if (fs.existsSync(VENV_DIR) && fs.existsSync(venvBinary("python"))) {
|
|
61
|
+
return; // already there
|
|
62
|
+
}
|
|
63
|
+
const py = findPython();
|
|
64
|
+
await execFileP(py.cmd, [...py.args, "-m", "venv", VENV_DIR]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Install lumo-tools into the Lumo-owned venv.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} opts
|
|
71
|
+
* @param {string} [opts.source] Local path to install from (used with --dev).
|
|
72
|
+
* When omitted, installs `lumo-tools` from PyPI.
|
|
73
|
+
*/
|
|
74
|
+
export async function installLumoTools(opts = {}) {
|
|
75
|
+
await ensureVenv();
|
|
76
|
+
const pip = venvBinary("pip");
|
|
77
|
+
const args = ["install", "--upgrade"];
|
|
78
|
+
if (opts.source) {
|
|
79
|
+
args.push("-e", opts.source);
|
|
80
|
+
} else {
|
|
81
|
+
args.push("lumo-tools");
|
|
82
|
+
}
|
|
83
|
+
await execFileP(pip, args, { maxBuffer: 20 * 1024 * 1024 });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Sanity check: each registered CLI binary actually exists in the venv. */
|
|
87
|
+
export function listInstalledBinaries() {
|
|
88
|
+
const names = ["lumo-wcag", "lumo-theory", "lumo-parity", "lumo-mcp"];
|
|
89
|
+
return names.map((name) => ({
|
|
90
|
+
name,
|
|
91
|
+
path: venvBinary(name),
|
|
92
|
+
exists: fs.existsSync(venvBinary(name)),
|
|
93
|
+
}));
|
|
94
|
+
}
|
package/src/lib/skill.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Skill file deployment.
|
|
3
|
+
*
|
|
4
|
+
* Copies the SKILL.md (and any references/) from the Lumo repo into the
|
|
5
|
+
* target client's skills directory. We resolve the source skill bundle
|
|
6
|
+
* relative to this installer file so it works in three modes:
|
|
7
|
+
*
|
|
8
|
+
* 1. `npm install -g lumo` — npm copied the installer next to the
|
|
9
|
+
* bundled skill/ directory.
|
|
10
|
+
* 2. `npx lumo init` — same as above but transient.
|
|
11
|
+
* 3. `node installer/src/index.js init --dev` from a fresh git clone —
|
|
12
|
+
* the skill/ directory is one level up from installer/.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from "node:fs";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
20
|
+
const __dirname = path.dirname(__filename);
|
|
21
|
+
|
|
22
|
+
/** Find the repo's skill/ directory regardless of install mode. */
|
|
23
|
+
export function findSkillSource() {
|
|
24
|
+
const candidates = [
|
|
25
|
+
path.resolve(__dirname, "..", "..", "..", "skill"), // installer/src/lib → repo/skill
|
|
26
|
+
path.resolve(__dirname, "..", "..", "skill"), // bundled-with-npm layout
|
|
27
|
+
path.resolve(__dirname, "skill"), // any other co-located case
|
|
28
|
+
];
|
|
29
|
+
for (const candidate of candidates) {
|
|
30
|
+
if (fs.existsSync(path.join(candidate, "SKILL.md"))) return candidate;
|
|
31
|
+
}
|
|
32
|
+
throw new Error(
|
|
33
|
+
"Could not find the Lumo skill/ directory next to the installer. " +
|
|
34
|
+
"If you're running from a custom checkout, pass --dev with the repo root."
|
|
35
|
+
);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Recursive copy. Overwrites existing files; never deletes the destination root. */
|
|
39
|
+
export function copyDirectory(src, dest) {
|
|
40
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
41
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
42
|
+
const from = path.join(src, entry.name);
|
|
43
|
+
const to = path.join(dest, entry.name);
|
|
44
|
+
if (entry.isDirectory()) {
|
|
45
|
+
copyDirectory(from, to);
|
|
46
|
+
} else {
|
|
47
|
+
fs.copyFileSync(from, to);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** Install the skill into a client's skill directory. Returns the dest path. */
|
|
53
|
+
export function installSkill(targetDir) {
|
|
54
|
+
const source = findSkillSource();
|
|
55
|
+
copyDirectory(source, targetDir);
|
|
56
|
+
return targetDir;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Remove a previously-installed skill directory (no-op if not present). */
|
|
60
|
+
export function removeSkill(targetDir) {
|
|
61
|
+
if (!fs.existsSync(targetDir)) return false;
|
|
62
|
+
fs.rmSync(targetDir, { recursive: true, force: true });
|
|
63
|
+
return true;
|
|
64
|
+
}
|