@pymodel/niblet 0.1.1 → 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/README.md +58 -40
- package/package.json +1 -1
- package/skill/niblet/SKILL.md +1 -1
- package/src/server.mjs +136 -12
- package/src/skill.mjs +103 -0
package/README.md
CHANGED
|
@@ -5,8 +5,6 @@
|
|
|
5
5
|
</picture>
|
|
6
6
|
</p>
|
|
7
7
|
|
|
8
|
-
<h1 align="center">Niblet MCP</h1>
|
|
9
|
-
|
|
10
8
|
<p align="center">Real screen references and a design skill, for coding agents that build UI.</p>
|
|
11
9
|
|
|
12
10
|
<p align="center">
|
|
@@ -27,31 +25,44 @@
|
|
|
27
25
|
By <a href="https://github.com/elkaix">elkaix</a> for <a href="https://github.com/PyModel">PyModel</a>
|
|
28
26
|
</p>
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
A coding agent building UI gets two things here: a design skill that keeps it working from your product rather than a generic template, and real screen references from the [Niblet](https://niblet.com) catalogue when a specific visual question is still open.
|
|
31
29
|
|
|
32
|
-
The skill works
|
|
30
|
+
The skill works alone. The server is optional and needs a token.
|
|
33
31
|
|
|
34
|
-
##
|
|
32
|
+
## Install the skill
|
|
35
33
|
|
|
36
|
-
|
|
34
|
+
```sh
|
|
35
|
+
npx skills add PyModel/niblet-skill-mcp --skill niblet -y
|
|
36
|
+
```
|
|
37
37
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
38
|
+
Or copy it yourself, keeping `references/`, `agents/`, `LICENSE`, and `NOTICE` alongside it:
|
|
39
|
+
|
|
40
|
+
```sh
|
|
41
|
+
cp -r skill/niblet ~/.claude/skills/niblet
|
|
42
|
+
```
|
|
42
43
|
|
|
43
|
-
|
|
44
|
+
Then ask for it by name:
|
|
45
|
+
|
|
46
|
+
> Use Niblet to design the checkout empty and error states.
|
|
44
47
|
|
|
45
|
-
|
|
48
|
+
The [workflow](skill/niblet/SKILL.md) settles the screen's job, primary action, hierarchy, existing tokens, real states, and acceptance criteria before writing anything. It finishes by rendering the surface and exercising it, so a green build on its own does not count as a pass.
|
|
46
49
|
|
|
47
|
-
|
|
50
|
+
## Connect the server
|
|
51
|
+
|
|
52
|
+
Pick one. Hosted, if your host speaks HTTP MCP:
|
|
48
53
|
|
|
49
54
|
```sh
|
|
50
55
|
claude mcp add --transport http niblet https://api.niblet.com/mcp \
|
|
51
56
|
--header "Authorization: Bearer $NIBLET_TOKEN"
|
|
52
57
|
```
|
|
53
58
|
|
|
54
|
-
|
|
59
|
+
Local over stdio, via the Claude Code CLI:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
claude mcp add niblet --env NIBLET_TOKEN=$NIBLET_TOKEN -- npx -y @pymodel/niblet
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Or the equivalent in any host's MCP config file:
|
|
55
66
|
|
|
56
67
|
```json
|
|
57
68
|
{
|
|
@@ -65,40 +76,46 @@ To run it locally over stdio instead, add this to your host's MCP config. Node.j
|
|
|
65
76
|
}
|
|
66
77
|
```
|
|
67
78
|
|
|
68
|
-
|
|
79
|
+
Node.js 24.15+; npx fetches the package on first launch. Get a token from [niblet.com/docs](https://niblet.com/docs) and keep it in your host's environment, never in a committed file or a chat message.
|
|
69
80
|
|
|
70
|
-
|
|
71
|
-
claude mcp add niblet --env NIBLET_TOKEN=$NIBLET_TOKEN -- npx -y @pymodel/niblet
|
|
72
|
-
```
|
|
73
|
-
|
|
74
|
-
Saving the config does not register the server — check the tool list your host actually reports.
|
|
75
|
-
|
|
76
|
-
Get a token from [niblet.com/docs](https://niblet.com/docs). Keep it in `.env` or your host's environment; never in a committed file or a chat message.
|
|
81
|
+
Saving the config does not register the server, so confirm it worked. `niblet_status` reports the configured origins, whether a usable token is present, and whether the API answers:
|
|
77
82
|
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
```
|
|
84
|
+
Token: present (44 characters, not shown).
|
|
85
|
+
Documents: 5/5 readable (niblet://skill, …).
|
|
86
|
+
API check: OK
|
|
87
|
+
```
|
|
81
88
|
|
|
82
|
-
|
|
89
|
+
## Tools
|
|
83
90
|
|
|
84
|
-
|
|
91
|
+
| Tool | Use it for | Token |
|
|
92
|
+
| --- | --- | --- |
|
|
93
|
+
| `find_ui_references` | One concrete unresolved question about a layout, state, or interaction. Returns one to three real screens as inline images. | yes |
|
|
94
|
+
| `find_ui_materials` | A font, icon, or animated icon role your design system does not already cover. Returns the recorded license with each result. | yes |
|
|
95
|
+
| `niblet_help` | "What can Niblet do?", or choosing between commands. Lists the four surface modes and every command; pass `command` for one entry. | no |
|
|
96
|
+
| `niblet_status` | Diagnosing the connection before concluding the catalogue is empty. Never prints the token. | no |
|
|
85
97
|
|
|
86
|
-
|
|
87
|
-
npx skills add PyModel/niblet-skill-mcp --skill niblet -y
|
|
88
|
-
```
|
|
98
|
+
The two catalogue tools match the hosted service exactly. `niblet_help` and `niblet_status` are local-only.
|
|
89
99
|
|
|
90
|
-
|
|
100
|
+
## Resources
|
|
91
101
|
|
|
92
|
-
|
|
93
|
-
cp -r skill/niblet ~/.claude/skills/niblet
|
|
94
|
-
```
|
|
102
|
+
The bundled documents, served without a token. Cross-links between them are rewritten to these URIs, so an agent reading one can follow every reference.
|
|
95
103
|
|
|
96
|
-
|
|
104
|
+
| URI | Contents |
|
|
105
|
+
| --- | --- |
|
|
106
|
+
| `niblet://skill` | The design workflow: modes, contract, state coverage, finish gate |
|
|
107
|
+
| `niblet://skill/commands` | Every command, its scope, and what completion means |
|
|
108
|
+
| `niblet://skill/connection` | Installing, invoking, and diagnosing the tools |
|
|
109
|
+
| `niblet://skill/evidence` | When to pull an external reference, and how to use one |
|
|
110
|
+
| `niblet://skill/native` | Platform constraints and the native finish gate |
|
|
97
111
|
|
|
98
|
-
|
|
99
|
-
> Run a niblet-skill review of the settings screen.
|
|
112
|
+
## Configuration
|
|
100
113
|
|
|
101
|
-
|
|
114
|
+
| Variable | Purpose |
|
|
115
|
+
| --- | --- |
|
|
116
|
+
| `NIBLET_TOKEN` | Required by the two catalogue tools. |
|
|
117
|
+
| `NIBLET_API_ORIGIN` | Retarget at a local deployment. Unset for production. |
|
|
118
|
+
| `NIBLET_MEDIA_ORIGIN` | Same, for images. Unset for production. |
|
|
102
119
|
|
|
103
120
|
## Contributing
|
|
104
121
|
|
|
@@ -107,11 +124,12 @@ git clone https://github.com/PyModel/niblet-skill-mcp
|
|
|
107
124
|
cd niblet-skill-mcp
|
|
108
125
|
npm ci --ignore-scripts
|
|
109
126
|
cp .env.example .env # then put your token in NIBLET_TOKEN
|
|
127
|
+
npm test
|
|
110
128
|
```
|
|
111
129
|
|
|
112
|
-
`npm test` covers the tool contract and its failure boundaries. For anything touching startup or configuration, also connect a real MCP client and confirm the tool list and `niblet://skill
|
|
130
|
+
`npm test` covers the tool contract and its failure boundaries. For anything touching startup or configuration, also connect a real MCP client and confirm the reported tool list and every `niblet://skill` resource. A resource that registers but never appears in `resources/list` is the failure unit tests cannot catch. For documentation, check that `npm pack --dry-run` still ships what you expect.
|
|
113
131
|
|
|
114
|
-
[AGENTS.md](AGENTS.md) has the
|
|
132
|
+
[AGENTS.md](AGENTS.md) has the working agreement for pointing a coding agent at this repository.
|
|
115
133
|
|
|
116
134
|
## License
|
|
117
135
|
|
package/package.json
CHANGED
package/skill/niblet/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: niblet
|
|
|
3
3
|
description: Keep interface work anchored to the product it belongs to instead of a generic template. Sets a short design contract, builds from the components and tokens already in the codebase, covers the states a surface can actually reach, and closes by looking at the rendered result. Use when building, reworking, or assessing a web or native interface. Trigger with "niblet", "niblet skill", "niblet designer ui", or "niblet review". Skip backend, CLI, data, and infrastructure work, prose-only tasks, and questions the product's own design system already settles.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.2.0"
|
|
7
7
|
author: "Mohamed Elkholy (elkaix)"
|
|
8
8
|
organization: "PyModel"
|
|
9
9
|
source: "https://github.com/PyModel/niblet-skill-mcp"
|
package/src/server.mjs
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises';
|
|
2
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
3
2
|
import { ErrorCode, McpError } from '@modelcontextprotocol/sdk/types.js';
|
|
4
3
|
import { z } from 'zod';
|
|
4
|
+
import { SKILL_DOCS, parseCommands, parseModes, readSkillDoc, uriFor } from './skill.mjs';
|
|
5
|
+
// The advertised server version is the package version; nothing else to keep in step.
|
|
6
|
+
import pkg from '../package.json' with { type: 'json' };
|
|
5
7
|
|
|
6
8
|
const DEFAULT_API_ORIGIN = 'https://api.niblet.com';
|
|
7
9
|
const DEFAULT_MEDIA_ORIGIN = 'https://media.niblet.com';
|
|
@@ -138,7 +140,9 @@ function listOf(data, key) {
|
|
|
138
140
|
}
|
|
139
141
|
|
|
140
142
|
/**
|
|
141
|
-
* Create a local stdio server
|
|
143
|
+
* Create a local stdio server. It exposes the hosted service's two catalogue tools with
|
|
144
|
+
* identical contracts, plus local-only helpers that read bundled files and need no token:
|
|
145
|
+
* niblet_help, niblet_status, and every skill document as a resource.
|
|
142
146
|
* Token, origins, and fetch injection are for embedding and tests; they never widen the
|
|
143
147
|
* destination allowlist beyond the configured API and media origins.
|
|
144
148
|
*/
|
|
@@ -152,8 +156,8 @@ export function createServer({
|
|
|
152
156
|
const MEDIA_ORIGINS = new Set([originOf(mediaOrigin, DEFAULT_MEDIA_ORIGIN), API_ORIGIN]);
|
|
153
157
|
|
|
154
158
|
const server = new McpServer(
|
|
155
|
-
{ name: 'niblet', version:
|
|
156
|
-
{ instructions: `Read niblet://skill for the Niblet design workflow. ${UNTRUSTED_DATA}
|
|
159
|
+
{ name: 'niblet', version: pkg.version, websiteUrl: 'https://niblet.com' },
|
|
160
|
+
{ instructions: `Read niblet://skill for the Niblet design workflow; its reference documents are served alongside it (niblet://skill/commands, /connection, /evidence, /native). Call niblet_help to list the surface modes and every design command, or when asked what Niblet can do; call niblet_status to diagnose the connection before concluding the catalogue is empty. ${UNTRUSTED_DATA} The two catalogue tools require NIBLET_TOKEN; the bundled skill, niblet_help, and niblet_status do not. This server only reads ${API_ORIGIN}/v1 and does not provide a remote UI review service.` },
|
|
157
161
|
);
|
|
158
162
|
|
|
159
163
|
function credentialError() {
|
|
@@ -328,17 +332,137 @@ export function createServer({
|
|
|
328
332
|
return textResult([MATERIAL_PREAMBLE, '', ...materials.map(materialText)].join('\n'));
|
|
329
333
|
});
|
|
330
334
|
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
335
|
+
// Every bundled document is served, not just SKILL.md: SKILL.md directs the agent
|
|
336
|
+
// to the command playbook, the evidence policy, and the native guidance, and over
|
|
337
|
+
// stdio those relative paths are unresolvable unless each one is also a resource.
|
|
338
|
+
for (const [slug, { title, description }] of Object.entries(SKILL_DOCS)) {
|
|
339
|
+
server.registerResource(slug === 'skill' ? 'niblet-skill' : `niblet-skill-${slug}`, uriFor(slug), {
|
|
340
|
+
title,
|
|
341
|
+
description,
|
|
342
|
+
mimeType: 'text/markdown',
|
|
343
|
+
}, async (uri) => {
|
|
344
|
+
const text = await readSkillDoc(slug);
|
|
345
|
+
if (text === null) throw new McpError(ErrorCode.InternalError, 'The bundled Niblet skill could not be read. Reinstall the package.');
|
|
338
346
|
return { contents: [{ uri: uri.href, mimeType: 'text/markdown', text }] };
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const localAnnotations = { ...annotations, openWorldHint: false };
|
|
351
|
+
|
|
352
|
+
server.registerTool('niblet_help', {
|
|
353
|
+
title: 'Niblet help',
|
|
354
|
+
description: 'List everything Niblet offers: the surface modes, every design command with its purpose, and the reference documents available as resources. Use when asked what Niblet can do, which command fits, or to present the choice menu before making changes.',
|
|
355
|
+
inputSchema: z.object({
|
|
356
|
+
command: z.string().min(1).max(64).optional().describe('A command name, to locate its section and full playbook entry.'),
|
|
357
|
+
}).strict(),
|
|
358
|
+
annotations: localAnnotations,
|
|
359
|
+
}, async (input) => {
|
|
360
|
+
let skillDoc;
|
|
361
|
+
let commandsDoc;
|
|
362
|
+
try {
|
|
363
|
+
[skillDoc, commandsDoc] = await Promise.all([readSkillDoc('skill'), readSkillDoc('commands')]);
|
|
339
364
|
} catch {
|
|
340
|
-
|
|
365
|
+
return errorResult('The bundled Niblet documents could not be read. Reinstall the package.');
|
|
366
|
+
}
|
|
367
|
+
const sections = parseCommands(commandsDoc ?? '');
|
|
368
|
+
const all = sections.flatMap((s) => s.commands.map((c) => ({ ...c, section: s.section })));
|
|
369
|
+
|
|
370
|
+
if (input.command) {
|
|
371
|
+
const wanted = input.command.trim().toLowerCase().replace(/^\//, '');
|
|
372
|
+
const match = all.find((c) => c.names.some((n) => n.toLowerCase() === wanted));
|
|
373
|
+
if (!match) {
|
|
374
|
+
return textResult([
|
|
375
|
+
`No Niblet command named "${input.command}".`,
|
|
376
|
+
`Available: ${all.flatMap((c) => c.names).join(', ')}.`,
|
|
377
|
+
`Full playbook: ${uriFor('commands')}`,
|
|
378
|
+
].join('\n'));
|
|
379
|
+
}
|
|
380
|
+
return textResult([
|
|
381
|
+
`${match.names.map((n) => `\`${n}\``).join(' / ')} — ${match.purpose}`,
|
|
382
|
+
`Section: ${match.section}.`,
|
|
383
|
+
'',
|
|
384
|
+
`Read ${uriFor('commands')} for the full entry, and ${uriFor('skill')} for the design contract and finish gate every implementation command applies.`,
|
|
385
|
+
].join('\n'));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// A compact index rather than the whole playbook: SKILL.md's routing rule asks for
|
|
389
|
+
// a short menu and a choice, not a wall of text.
|
|
390
|
+
const modes = parseModes(skillDoc ?? '');
|
|
391
|
+
const lines = ['Niblet keeps interface work anchored to the product it belongs to. Pick a mode and a command, then work under a design contract.'];
|
|
392
|
+
if (modes.length) {
|
|
393
|
+
lines.push('', 'Surface modes — choose by the job of the surface:');
|
|
394
|
+
for (const m of modes) lines.push(` ${m.mode} — ${m.job} (${m.surfaces})`);
|
|
395
|
+
}
|
|
396
|
+
for (const section of sections) {
|
|
397
|
+
lines.push('', `${section.section}:`);
|
|
398
|
+
for (const c of section.commands) lines.push(` ${c.names.join(' / ')} — ${c.purpose}`);
|
|
399
|
+
}
|
|
400
|
+
lines.push('', 'Reference documents (read as MCP resources):');
|
|
401
|
+
for (const [slug, doc] of Object.entries(SKILL_DOCS)) lines.push(` ${uriFor(slug)} — ${doc.title}`);
|
|
402
|
+
lines.push('', 'Catalogue tools: find_ui_references (real full-screen references), find_ui_materials (license-recorded fonts and icons). Both need NIBLET_TOKEN; run niblet_status to check. Reference retrieval is optional and never a prerequisite to useful work.');
|
|
403
|
+
lines.push('With no target or command, present this menu and wait for a choice rather than making changes.');
|
|
404
|
+
return textResult(lines.join('\n'));
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
server.registerTool('niblet_status', {
|
|
408
|
+
title: 'Niblet status',
|
|
409
|
+
description: 'Diagnose this Niblet connection: configured origins, whether a usable token is present, the bundled documents, and whether the catalogue API actually answers. Use before concluding that the catalogue is empty or broken.',
|
|
410
|
+
inputSchema: z.object({
|
|
411
|
+
probe: z.boolean().default(true).describe('Contact the configured API to confirm it answers. Set false to report configuration only.'),
|
|
412
|
+
}).strict(),
|
|
413
|
+
// The probe is the reported result, so this one does reach the configured origin.
|
|
414
|
+
annotations,
|
|
415
|
+
}, async (input, extra) => {
|
|
416
|
+
const lines = ['Niblet MCP status.', '', `API origin: ${API_ORIGIN}`, `Media origins: ${[...MEDIA_ORIGINS].join(', ')}`];
|
|
417
|
+
|
|
418
|
+
// Presence and shape only — the playbook's doctor entry requires never displaying it.
|
|
419
|
+
const credential = credentialError();
|
|
420
|
+
if (typeof token !== 'string' || token.trim() === '') lines.push('Token: not configured. Set NIBLET_TOKEN in the MCP server environment.');
|
|
421
|
+
else if (credential) lines.push('Token: present but malformed for a bearer credential. Check NIBLET_TOKEN.');
|
|
422
|
+
else lines.push(`Token: present (${token.trim().length} characters, not shown).`);
|
|
423
|
+
|
|
424
|
+
const docs = await Promise.all(Object.keys(SKILL_DOCS).map(async (slug) => {
|
|
425
|
+
try {
|
|
426
|
+
return (await readSkillDoc(slug)) ? slug : null;
|
|
427
|
+
} catch {
|
|
428
|
+
return null;
|
|
429
|
+
}
|
|
430
|
+
}));
|
|
431
|
+
const readable = docs.filter(Boolean);
|
|
432
|
+
lines.push(`Documents: ${readable.length}/${Object.keys(SKILL_DOCS).length} readable (${readable.map(uriFor).join(', ')}).`);
|
|
433
|
+
lines.push('Tools: find_ui_references, find_ui_materials, niblet_help, niblet_status.');
|
|
434
|
+
|
|
435
|
+
if (!input.probe) {
|
|
436
|
+
lines.push('', 'API not contacted (probe disabled).');
|
|
437
|
+
return textResult(lines.join('\n'));
|
|
438
|
+
}
|
|
439
|
+
if (credential) {
|
|
440
|
+
lines.push('', 'API not contacted: no usable token. The bundled documents and niblet_help remain available without one.');
|
|
441
|
+
return textResult(lines.join('\n'));
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const result = await requestJson(['stats'], {}, extra.signal);
|
|
445
|
+
if (!result.ok) {
|
|
446
|
+
// Any HTTP status means the origin answered, which is what reachability asks.
|
|
447
|
+
// /v1/stats is not part of the hosted contract, so a 404 is a normal answer
|
|
448
|
+
// from a healthy deployment, not a failure.
|
|
449
|
+
if (result.status === 404) {
|
|
450
|
+
lines.push('', 'API check: reachable — the configured origin answered. It does not serve catalogue counts; use find_ui_references to confirm the catalogue itself.');
|
|
451
|
+
return textResult(lines.join('\n'));
|
|
452
|
+
}
|
|
453
|
+
if (result.status !== undefined) {
|
|
454
|
+
lines.push('', `API check: reachable, but the request was rejected — ${result.message}`);
|
|
455
|
+
return textResult(lines.join('\n'));
|
|
456
|
+
}
|
|
457
|
+
lines.push('', `API check: FAILED — ${result.message}`);
|
|
458
|
+
lines.push(`Check that the configured origin is correct and that the service is actually running, then read the connection guide at ${uriFor('connection')}.`);
|
|
459
|
+
return textResult(lines.join('\n'));
|
|
341
460
|
}
|
|
461
|
+
const counts = ['apps', 'screens', 'captioned', 'journeys']
|
|
462
|
+
.map((key) => (typeof result.data[key] === 'number' ? `${key} ${result.data[key]}` : null))
|
|
463
|
+
.filter(Boolean);
|
|
464
|
+
lines.push('', `API check: OK${counts.length ? ` — catalogue holds ${counts.join(', ')}.` : '.'}`);
|
|
465
|
+
return textResult(lines.join('\n'));
|
|
342
466
|
});
|
|
343
467
|
|
|
344
468
|
return server;
|
package/src/skill.mjs
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Bundled-skill access: reading the documents, making their cross-links usable
|
|
3
|
+
* over MCP, and deriving the command index from them.
|
|
4
|
+
*
|
|
5
|
+
* The same files are also loaded directly as a filesystem skill, where the
|
|
6
|
+
* relative links between them are correct. They are only wrong for a client that
|
|
7
|
+
* reads them over stdio and has no access to the package directory, so the
|
|
8
|
+
* rewrite happens on the way out rather than in the files themselves.
|
|
9
|
+
*/
|
|
10
|
+
import { readFile } from 'node:fs/promises';
|
|
11
|
+
|
|
12
|
+
/** slug -> { file, title, description }. `skill` is SKILL.md; the rest are its references. */
|
|
13
|
+
export const SKILL_DOCS = {
|
|
14
|
+
skill: {
|
|
15
|
+
file: 'SKILL.md',
|
|
16
|
+
title: 'Niblet design skill',
|
|
17
|
+
description: 'The bundled Niblet design workflow: modes, design contract, state coverage, and the rendered finish gate. Available without an API token.',
|
|
18
|
+
},
|
|
19
|
+
commands: {
|
|
20
|
+
file: 'references/commands.md',
|
|
21
|
+
title: 'Niblet command playbook',
|
|
22
|
+
description: 'Every Niblet command — what it does, its scope, and what completion means. Referenced by the design workflow.',
|
|
23
|
+
},
|
|
24
|
+
connection: {
|
|
25
|
+
file: 'references/connection.md',
|
|
26
|
+
title: 'Niblet connection guide',
|
|
27
|
+
description: 'Installing and invoking the MCP tools, diagnosing a connection, and the limits of host-dependent helpers.',
|
|
28
|
+
},
|
|
29
|
+
evidence: {
|
|
30
|
+
file: 'references/evidence.md',
|
|
31
|
+
title: 'Niblet evidence policy',
|
|
32
|
+
description: 'When to retrieve an external reference, and how to use one without copying it.',
|
|
33
|
+
},
|
|
34
|
+
native: {
|
|
35
|
+
file: 'references/native.md',
|
|
36
|
+
title: 'Niblet native platform guidance',
|
|
37
|
+
description: 'Platform constraints and the native finish-gate batch for iOS and other native work.',
|
|
38
|
+
},
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export const uriFor = (slug) => (slug === 'skill' ? 'niblet://skill' : `niblet://skill/${slug}`);
|
|
42
|
+
|
|
43
|
+
/** Relative markdown targets in the bundled docs -> the resource URI serving the same document. */
|
|
44
|
+
const LINK_REWRITES = [
|
|
45
|
+
[/\]\(\.\.\/SKILL\.md\)/g, `](${uriFor('skill')})`],
|
|
46
|
+
[/\]\(SKILL\.md\)/g, `](${uriFor('skill')})`],
|
|
47
|
+
...Object.entries(SKILL_DOCS)
|
|
48
|
+
.filter(([slug]) => slug !== 'skill')
|
|
49
|
+
.flatMap(([slug]) => [
|
|
50
|
+
[new RegExp(`\\]\\(references/${slug}\\.md\\)`, 'g'), `](${uriFor(slug)})`],
|
|
51
|
+
[new RegExp(`\\]\\(${slug}\\.md\\)`, 'g'), `](${uriFor(slug)})`],
|
|
52
|
+
]),
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Point a document's cross-links at the resources that serve them. Without this a
|
|
57
|
+
* client that reads niblet://skill is told to open `references/commands.md`, a path
|
|
58
|
+
* it has no way to resolve.
|
|
59
|
+
*/
|
|
60
|
+
export function rewriteLinks(text) {
|
|
61
|
+
return LINK_REWRITES.reduce((acc, [pattern, replacement]) => acc.replace(pattern, replacement), text);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function readSkillDoc(slug) {
|
|
65
|
+
const entry = SKILL_DOCS[slug];
|
|
66
|
+
if (!entry) return null;
|
|
67
|
+
const text = await readFile(new URL(`../skill/niblet/${entry.file}`, import.meta.url), 'utf8');
|
|
68
|
+
return rewriteLinks(text);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The command index, derived from commands.md so it cannot drift from the playbook.
|
|
73
|
+
* Headings are `## Section` and ``### `name` — purpose``.
|
|
74
|
+
*/
|
|
75
|
+
export function parseCommands(markdown) {
|
|
76
|
+
const sections = [];
|
|
77
|
+
let current = null;
|
|
78
|
+
for (const line of markdown.split('\n')) {
|
|
79
|
+
const section = /^##\s+(?!#)(.+?)\s*$/.exec(line);
|
|
80
|
+
if (section) {
|
|
81
|
+
current = { section: section[1], commands: [] };
|
|
82
|
+
sections.push(current);
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const command = /^###\s+(.+?)\s+—\s+(.+?)\s*$/.exec(line);
|
|
86
|
+
if (command && current) {
|
|
87
|
+
// Names arrive as `polish`, or `pin` / `unpin` for a paired helper.
|
|
88
|
+
const names = [...command[1].matchAll(/`([^`]+)`/g)].map((m) => m[1]);
|
|
89
|
+
if (names.length) current.commands.push({ names, purpose: command[2] });
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return sections.filter((s) => s.commands.length);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The four surface modes, from the table in SKILL.md, for the same no-drift reason. */
|
|
96
|
+
export function parseModes(markdown) {
|
|
97
|
+
const modes = [];
|
|
98
|
+
for (const line of markdown.split('\n')) {
|
|
99
|
+
const row = /^\|\s*\*\*(.+?)\*\*\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*(.+?)\s*\|\s*$/.exec(line);
|
|
100
|
+
if (row) modes.push({ mode: row[1], job: row[2], surfaces: row[4] });
|
|
101
|
+
}
|
|
102
|
+
return modes;
|
|
103
|
+
}
|