@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 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
+ }
@@ -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
+ }