molecule-mcp 0.2.0 → 0.3.0

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 CHANGED
@@ -5,9 +5,8 @@ The [Molecule.dev](https://molecule.dev) MCP server — structured tools over th
5
5
  fleet, list flagship app templates, scaffold projects, add/wire packages, swap
6
6
  providers, and read package docs.
7
7
 
8
- > **Status:** built and tested; publishes to npm at launch. The `npx -y
9
- molecule-mcp` entries below are the install-time interface. The
10
- > `@molecule/*` packages themselves are public on npm today.
8
+ > On npm as `molecule-mcp`: the `npx -y molecule-mcp` entries below run as
9
+ > written.
11
10
 
12
11
  Also ships the molecule doctrine as a resource (`molecule://conventions`) and
13
12
  an `agent_init` tool that writes the convention files your harness reads — so
@@ -94,9 +93,10 @@ your agent can read as context.
94
93
 
95
94
  ```jsonc
96
95
  // search_packages — capability search, agent-side package discovery
97
- { "query": "payments", "limit": 3 }
96
+ { "query": "payments", "stack": "api", "limit": 3 }
98
97
  // → [{"name":"@molecule/api-payments-stripe","stack":"api","category":"payments",
99
- // "description":"Stripe payment provider…","install":"mlcl add @molecule/api-payments-stripe --inject"}, …]
98
+ // "label":"Stripe","description":"Stripe payment provider…",
99
+ // "install":"mlcl add @molecule/api-payments-stripe --inject"}, …]
100
100
 
101
101
  // scaffold_project — unattended scaffold, JSON result
102
102
  { "name": "my-store", "template": "online-store", "skip_install": false }
@@ -130,6 +130,24 @@ project, through that project's API key:
130
130
  3. Service tools (`embed_text`, `text_to_speech`, `transcribe_audio`, …) read the key from that `.env` (pass
131
131
  `directory`) or from `MOLECULE_API_KEY` in this server's environment.
132
132
 
133
+ Key resolution and `env_file` share one rule — the only names either side
134
+ accepts are `.env` and `.env.<variant>` (e.g. `.env.local`); placeholder
135
+ variants such as `.env.example`, `.env.schema` and `.env.template` are
136
+ refused on write and never read. Given a `directory`, resolution checks, in
137
+ order:
138
+
139
+ 1. `MOLECULE_API_KEY` in this server's environment (always wins);
140
+ 2. `directory/.env`, then every `directory/.env.<variant>` in byte order,
141
+ then the same set in `directory/api`;
142
+ 3. the same two locations in each parent, up to two parents.
143
+
144
+ Inside one file the ordinary dotenv rules apply (Node's `util.parseEnv`):
145
+ inline `#` comments, single/double quotes, an `export` prefix and multi-line
146
+ quoted values are handled, and a duplicate assignment resolves to the last
147
+ one. `create_api_key` rewrites the key's assignment line — dropping older
148
+ duplicates — so the key it mints is always the one a later service call
149
+ reads.
150
+
133
151
  The same HTTP API (`POST https://api.molecule.dev/api/v1/services/<service>/<operation>`,
134
152
  catalog at `GET …/v1/services`) backs the `@molecule/*-molecule` bonds, so
135
153
  app code reaches the same service through a normal, swappable bond.
@@ -19,10 +19,41 @@ OAuth provider for another by changing one wiring file, not rewriting the app).
19
19
 
20
20
  - `npx mlcl add @molecule/<name> --inject` — install + wire a package
21
21
  - `npx mlcl swap @molecule/<new-provider>` — swap in a different provider for a wired capability
22
+ - `npx mlcl resources --contract <openapi file>` — write `app/src/resources/*` from the API contract the app serves (typed lists, records, creates and updates over the query cache); run it again when the contract changes
22
23
  - `npx mlcl mcp install` — register the molecule MCP tools (search/scaffold/add/swap/docs) with this harness
23
24
 
25
+ **Before you build a capability** — a feature, an integration, a script, a tool:
26
+
27
+ 1. **Inspect.** Start with `## Declared capability map` below when present. Map
28
+ every feature in the plan to its declared package and configuration; otherwise
29
+ use `npx mlcl search <capability>` (or `search_packages` over MCP). Read the
30
+ package's contract with `read_package_doc` or at
31
+ `node_modules/@molecule/<name>/README.md` before using it OR deciding not to.
32
+ Write `use @molecule/<name> with <configuration>` or
33
+ `Not using @molecule/<name> because <reason> (doc read)` for each feature. A
34
+ different storage key, header scheme, response envelope or endpoint is
35
+ configuration to look up, not a reason to write a parallel implementation.
36
+ 2. **Reuse.** Call that package's API and the provider already wired for it
37
+ rather than re-implementing or copying it.
38
+ 3. **Adapt.** If the shared capability is genuinely awkward or missing a piece,
39
+ fix it in the package or bond that owns it, then consume the fixed contract —
40
+ never fork it locally.
41
+ 4. **Create only for a reusable gap.** A typed interface in a core package,
42
+ provider bonds per vendor, and ONE thin composition root (`src/bonds/`) that
43
+ picks the vendor; everything behind it talks to the interface only.
44
+
45
+ Single-use helpers, app-specific business logic and one-off scripts stay local —
46
+ do not extract a package for them.
47
+
24
48
  **Inside a molecule-built project:** follow its AGENTS.md — prefer
25
49
  `@molecule/*` packages over hand-rolled code, keep provider wiring in
26
50
  `src/bonds/`, style via ClassMap (`getClassMap()`), route all user-facing text
27
51
  through i18n. Every installed package documents its own API at
28
52
  `node_modules/@molecule/<name>/README.md`.
53
+
54
+ **While implementing a supplied Playwright suite:** start `npx mlcl test-feedback
55
+ --suite <spec-directory> --sources <app-source-directory> --command "<the suite’s native
56
+ Playwright command targeting the local app>" --watch --json` as a background task.
57
+ It runs the real spec files while you build and reports native locator failures
58
+ with the rendered page and source paths. Passing runs are silent; keep building
59
+ without waiting. This is diagnosis; run the full suite for final verification.
@@ -0,0 +1,15 @@
1
+ import * as fs from 'node:fs';
2
+ import type { DocDeclaration } from './package-doc-search.js';
3
+ /** Bounded declaration reader, also serialized into the sandbox; keep it self-contained. */
4
+ export declare function readDocDeclarations(root: string, io: typeof fs): {
5
+ declarations: DocDeclaration[];
6
+ note?: string;
7
+ };
8
+ /** Read declarations beside the exact README selected by the existing doc resolver. */
9
+ export declare function declarationsBesideReadme(readmePath?: string): ReturnType<typeof readDocDeclarations>;
10
+ /** Local MCP resolution follows hoisted node_modules and never selects a different copy's types. */
11
+ export declare function localPackageDoc(name: string, base: string): {
12
+ readme: string;
13
+ declarations: DocDeclaration[];
14
+ note?: string;
15
+ } | null;
@@ -0,0 +1,121 @@
1
+ import * as fs from 'node:fs';
2
+ import { dirname, resolve } from 'node:path';
3
+ /** Bounded declaration reader, also serialized into the sandbox; keep it self-contained. */
4
+ export function readDocDeclarations(root, io) {
5
+ const declarations = [];
6
+ let remaining = 524_288;
7
+ let visited = 0;
8
+ let cut = false;
9
+ function walk(relative, depth) {
10
+ if (depth > 12 || visited >= 1_024 || declarations.length >= 128 || remaining <= 0) {
11
+ cut = true;
12
+ return;
13
+ }
14
+ let entries;
15
+ try {
16
+ entries = io.readdirSync(root + '/' + relative, { withFileTypes: true });
17
+ }
18
+ catch (error) {
19
+ if (error.code !== 'ENOENT')
20
+ cut = true;
21
+ return;
22
+ }
23
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
24
+ if (++visited > 1_024 || declarations.length >= 128 || remaining <= 0) {
25
+ cut = true;
26
+ break;
27
+ }
28
+ const path = relative + '/' + entry.name;
29
+ if (entry.isSymbolicLink()) {
30
+ cut = true;
31
+ continue;
32
+ }
33
+ if (entry.isDirectory()) {
34
+ walk(path, depth + 1);
35
+ continue;
36
+ }
37
+ if (!entry.isFile() || !path.endsWith('.d.ts'))
38
+ continue;
39
+ let fd;
40
+ try {
41
+ fd = io.openSync(root + '/' + path, 'r');
42
+ const actual = io.realpathSync('/proc/self/fd/' + fd);
43
+ const before = io.fstatSync(fd);
44
+ if (!actual.startsWith(root + '/') ||
45
+ !before.isFile() ||
46
+ before.size > Math.min(262_144, remaining)) {
47
+ cut = true;
48
+ continue;
49
+ }
50
+ const bytes = Buffer.alloc(before.size + 1);
51
+ let length = 0;
52
+ while (length < bytes.length) {
53
+ const n = io.readSync(fd, bytes, length, bytes.length - length, null);
54
+ if (!n)
55
+ break;
56
+ length += n;
57
+ }
58
+ const after = io.fstatSync(fd);
59
+ if (length !== before.size ||
60
+ after.size !== before.size ||
61
+ after.mtimeMs !== before.mtimeMs) {
62
+ cut = true;
63
+ continue;
64
+ }
65
+ declarations.push({
66
+ path,
67
+ text: new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(0, length)),
68
+ });
69
+ remaining -= length;
70
+ }
71
+ catch (_error) {
72
+ cut = true; // A partial scan must never establish declaration absence.
73
+ }
74
+ finally {
75
+ if (fd !== undefined)
76
+ io.closeSync(fd);
77
+ }
78
+ }
79
+ }
80
+ walk('dist', 0);
81
+ return {
82
+ declarations,
83
+ ...(cut
84
+ ? {
85
+ note: 'Declaration scan incomplete (file/byte/depth cap or unreadable/unsafe path); no-hit terms do not establish absence.',
86
+ }
87
+ : {}),
88
+ };
89
+ }
90
+ /** Read declarations beside the exact README selected by the existing doc resolver. */
91
+ export function declarationsBesideReadme(readmePath) {
92
+ if (!readmePath)
93
+ return { declarations: [] };
94
+ return readDocDeclarations(fs.realpathSync(dirname(readmePath)), fs);
95
+ }
96
+ /** Local MCP resolution follows hoisted node_modules and never selects a different copy's types. */
97
+ export function localPackageDoc(name, base) {
98
+ let dir = resolve(base);
99
+ for (;;) {
100
+ const root = resolve(dir, 'node_modules', '@molecule', name);
101
+ for (const readme of ['README.md', 'dist/README.md']) {
102
+ try {
103
+ const realRoot = fs.realpathSync(root);
104
+ const path = fs.realpathSync(resolve(realRoot, readme));
105
+ if (!path.startsWith(realRoot + '/'))
106
+ continue;
107
+ const stat = fs.statSync(path);
108
+ if (!stat.isFile() || stat.size > 1_048_576)
109
+ continue;
110
+ return { readme: fs.readFileSync(path, 'utf8'), ...readDocDeclarations(realRoot, fs) };
111
+ }
112
+ catch (_error) {
113
+ // No installed doc at this node_modules level; continue the normal upward lookup.
114
+ }
115
+ }
116
+ const parent = dirname(dir);
117
+ if (parent === dir)
118
+ return null;
119
+ dir = parent;
120
+ }
121
+ }
@@ -0,0 +1,20 @@
1
+ import { z } from 'zod';
2
+ /** Literal, bounded README/type-reference search. Mirrored in molecule-mcp (no shared runtime dependency). */
3
+ /** One installed declaration file; paths are relative to the package root. */
4
+ export interface DocDeclaration {
5
+ path: string;
6
+ text: string;
7
+ }
8
+ /** Search output fits beneath the existing doc tool's transport budget. */
9
+ export declare const DOC_SEARCH_TOTAL_CHARS = 24000;
10
+ /** Validate the scalar/array input before opening any package files. */
11
+ export declare function parseDocSearch(value: unknown): string[];
12
+ /** Search README lines (one context line either side) and complete declaration blocks. */
13
+ export declare function searchPackageDoc(readme: string, terms: readonly string[], declarations?: readonly DocDeclaration[], sourceNote?: string): {
14
+ doc: string;
15
+ search: readonly string[];
16
+ truncated?: boolean;
17
+ note?: string;
18
+ };
19
+ /** The same bounded search input on stdio and hosted MCP. */
20
+ export declare const docSearchSchema: z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>;
@@ -0,0 +1,199 @@
1
+ import { z } from 'zod';
2
+ /** Search output fits beneath the existing doc tool's transport budget. */
3
+ export const DOC_SEARCH_TOTAL_CHARS = 24_000;
4
+ const TERM_CHARS = 6_000;
5
+ const SOURCE_CHARS = 2_600;
6
+ const DECLARATION_CHARS = 1_800;
7
+ /** Validate the scalar/array input before opening any package files. */
8
+ export function parseDocSearch(value) {
9
+ const terms = typeof value === 'string' ? [value] : value;
10
+ if (!Array.isArray(terms) ||
11
+ terms.length < 1 ||
12
+ terms.length > 6 ||
13
+ terms.some((term) => typeof term !== 'string' || !term.trim() || term.length > 200))
14
+ throw new Error('`search` must be one non-empty string or 1–6 non-empty strings (max 200 characters each).');
15
+ return terms;
16
+ }
17
+ /** Locate top-level declaration starts without treating quoted/commented braces as structure. */
18
+ function declarationRanges(lines) {
19
+ const masked = lines
20
+ .join('\n')
21
+ .replace(/\/\*[\s\S]*?\*\/|\/\/[^\n]*|"(?:\\.|[^"\\])*"|'(?:\\.|[^'\\])*'|`(?:\\.|[^`\\])*`/g, (text) => text.replace(/[^\n]/g, ' '))
22
+ .split('\n');
23
+ const starts = [];
24
+ let depth = 0;
25
+ for (let i = 0; i < masked.length; i++) {
26
+ if (depth === 0 &&
27
+ /^\s*(?:(?:export|declare|default|abstract)\s+)*(?:interface|type|class|function|const|let|var|enum|namespace|module|global|import|export)\b/.test(masked[i])) {
28
+ // The preceding comment belongs to this declaration, not the previous export.
29
+ let start = i;
30
+ while (start > 0 && !masked[start - 1].trim())
31
+ start--;
32
+ while (start < i && !lines[start].trim())
33
+ start++;
34
+ starts.push(start);
35
+ }
36
+ for (const char of masked[i]) {
37
+ if ('{[('.includes(char))
38
+ depth++;
39
+ if ('}])'.includes(char))
40
+ depth = Math.max(0, depth - 1);
41
+ }
42
+ }
43
+ if (starts[0] !== 0)
44
+ starts.unshift(0);
45
+ return starts.map((start, i) => [start, (starts[i + 1] ?? lines.length) - 1]);
46
+ }
47
+ /** Retain the literal hit when a generated signature occupies one very long line. */
48
+ function searchLine(line, needle, cap) {
49
+ if (line.length <= cap)
50
+ return line;
51
+ const match = line.toLowerCase().indexOf(needle);
52
+ const start = Math.max(0, match - 100);
53
+ return `${start ? '…' : ''}${line.slice(start, start + cap)}…(line truncated)`;
54
+ }
55
+ /** Search README lines (one context line either side) and complete declaration blocks. */
56
+ export function searchPackageDoc(readme, terms, declarations = [], sourceNote) {
57
+ let truncated = false;
58
+ const readmeLines = readme.split(/\r?\n/);
59
+ const headings = [];
60
+ let heading = '(introduction)';
61
+ let fence = '';
62
+ for (const line of readmeLines) {
63
+ const marker = line.match(/^\s{0,3}(`{3,}|~{3,})/);
64
+ if (marker) {
65
+ if (!fence)
66
+ fence = marker[1][0];
67
+ else if (marker[1][0] === fence)
68
+ fence = '';
69
+ }
70
+ const title = !fence && line.match(/^\s{0,3}#{1,6}\s+(.+?)(?:\s+#+)?\s*$/);
71
+ if (title)
72
+ heading = title[1];
73
+ headings.push(heading);
74
+ }
75
+ const files = declarations.map((file) => {
76
+ const lines = file.text.split(/\r?\n/);
77
+ return { ...file, lines, ranges: declarationRanges(lines) };
78
+ });
79
+ const output = [];
80
+ for (const [termIndex, term] of terms.entries()) {
81
+ const needle = term.toLowerCase();
82
+ const readmeHits = [];
83
+ const declarationHits = [];
84
+ let readmeCut = false;
85
+ let declarationCut = false;
86
+ let readmeSize = 0;
87
+ let declarationSize = 0;
88
+ let last = -1;
89
+ for (let i = 0; i < readmeLines.length; i++) {
90
+ if (!readmeLines[i].toLowerCase().includes(needle))
91
+ continue;
92
+ const start = Math.max(last + 1, i - 1, 0);
93
+ const end = Math.min(readmeLines.length - 1, i + 1);
94
+ if (start > end)
95
+ continue;
96
+ const hit = `README.md:${i + 1} — section ${JSON.stringify(headings[i])}\n` +
97
+ readmeLines
98
+ .slice(start, end + 1)
99
+ .map((line, j) => `${start + j + 1}: ${searchLine(line, needle, start + j === i ? 1_400 : 300)}`)
100
+ .join('\n');
101
+ if (hit.includes('…(line truncated)'))
102
+ readmeCut = true;
103
+ if (readmeSize + hit.length > SOURCE_CHARS) {
104
+ readmeCut = true;
105
+ break;
106
+ }
107
+ readmeHits.push(hit);
108
+ readmeSize += hit.length;
109
+ last = end;
110
+ }
111
+ for (const file of files) {
112
+ for (const [start, end] of file.ranges) {
113
+ let match = start;
114
+ while (match <= end && !file.lines[match].toLowerCase().includes(needle))
115
+ match++;
116
+ if (match > end)
117
+ continue;
118
+ let text = file.lines
119
+ .slice(start, end + 1)
120
+ .join('\n')
121
+ .trimEnd();
122
+ let at = start;
123
+ if (text.length > DECLARATION_CHARS) {
124
+ // Keep the matching line visible even inside a very large interface.
125
+ at = Math.max(start, match - 1);
126
+ const before = at < match ? searchLine(file.lines[at], needle, 200) + '\n' : '';
127
+ text = (before +
128
+ searchLine(file.lines[match], needle, 1_400) +
129
+ '\n' +
130
+ file.lines.slice(match + 1, end + 1).join('\n')).slice(0, DECLARATION_CHARS);
131
+ text += '\n…(declaration truncated; read this path for the complete declaration)';
132
+ declarationCut = true;
133
+ }
134
+ const hit = `${file.path}:${at + 1}\n${text}`;
135
+ if (declarationSize + hit.length > SOURCE_CHARS) {
136
+ declarationCut = true;
137
+ break;
138
+ }
139
+ declarationHits.push(hit);
140
+ declarationSize += hit.length;
141
+ }
142
+ }
143
+ const chunks = [`### Search: ${JSON.stringify(term)}`, ...readmeHits, ...declarationHits];
144
+ if (readmeCut)
145
+ chunks.push('…(README matches truncated by the per-term cap)');
146
+ if (declarationCut)
147
+ chunks.push('…(declaration matches truncated by the per-term cap)');
148
+ if (!readmeHits.length && !declarationHits.length && !readmeCut && !declarationCut)
149
+ chunks.push('No matches in README.md or dist/**/*.d.ts.');
150
+ let block = chunks.join('\n\n');
151
+ if (block.length > TERM_CHARS) {
152
+ block = block.slice(0, TERM_CHARS - 100) + '\n…(per-term search cap reached)';
153
+ truncated = true;
154
+ }
155
+ const reserve = terms
156
+ .slice(termIndex + 1)
157
+ .reduce((size, value) => size + JSON.stringify(value).length + 100, 700);
158
+ if (output.join('\n\n').length + block.length > DOC_SEARCH_TOTAL_CHARS - reserve) {
159
+ output.push(`### Search: ${JSON.stringify(term)}\nOmitted — total search cap reached.`);
160
+ truncated = true;
161
+ }
162
+ else {
163
+ output.push(block);
164
+ truncated ||= readmeCut || declarationCut;
165
+ }
166
+ }
167
+ const note = [
168
+ !declarations.length ? 'No installed dist/**/*.d.ts declarations available.' : '',
169
+ sourceNote?.slice(0, 500),
170
+ ]
171
+ .filter(Boolean)
172
+ .join(' ');
173
+ if (note)
174
+ output.push(note);
175
+ return {
176
+ doc: output.join('\n\n'),
177
+ search: terms,
178
+ ...(truncated ? { truncated: true } : {}),
179
+ ...(note ? { note } : {}),
180
+ };
181
+ }
182
+ /** The same bounded search input on stdio and hosted MCP. */
183
+ export const docSearchSchema = z
184
+ .union([
185
+ z
186
+ .string()
187
+ .min(1)
188
+ .max(200)
189
+ .refine((term) => !!term.trim()),
190
+ z
191
+ .array(z
192
+ .string()
193
+ .min(1)
194
+ .max(200)
195
+ .refine((term) => !!term.trim()))
196
+ .min(1)
197
+ .max(6),
198
+ ])
199
+ .describe('Case-insensitive literal string or up to 6 terms. Search README lines with context/headings and available installed dist/**/*.d.ts declarations; never implementation JS.');
@@ -0,0 +1,171 @@
1
+ /**
2
+ * The canonical package-search contract shared by `mlcl search` and the hosted
3
+ * MCP server.
4
+ *
5
+ * This module is PURE — registry-shaped data in, ranked hits out: no filesystem,
6
+ * no registry loader, no oclif/CLI, and no vendor or `@molecule/*` import. That
7
+ * is what lets the standalone hosted MCP (`mcp/src/package-search.ts`, which
8
+ * must build from the `mcp/` package alone — its Dockerfile and
9
+ * `scripts/deploy-remote.sh` ship no CLI or workspace) carry the SAME ranking,
10
+ * stack filtering and DTO instead of hand-maintaining a second scorer.
11
+ *
12
+ * The copy under `mcp/src/` is generated from this file by
13
+ * `scripts/generate-mcp-package-search.mjs`; `src/lib/__tests__/mcp-search-owner-drift.test.ts`
14
+ * fails when the two diverge. Edit this file, then regenerate — never the copy.
15
+ *
16
+ * Input is the loose registry-entry shape, because the two readers hold
17
+ * different views of one registry: the CLI adapts `loadRegistry()`'s
18
+ * `InferredPackage` (flattening its `molecule` metadata), and the hosted server
19
+ * passes the raw registry entry. Both therefore reach the same scorer.
20
+ *
21
+ * @module
22
+ */
23
+ /**
24
+ * One package as either registry reader can hand it to the search contract.
25
+ *
26
+ * Every metadata field is optional because the hosted side reads an
27
+ * UNVALIDATED registry document: an entry missing a label, or carrying a
28
+ * non-array `exports`, must degrade to a score (or no match), never throw
29
+ * inside the tool handler.
30
+ */
31
+ export interface SearchSourceEntry {
32
+ /** Full package name, e.g. `@molecule/api-payments-stripe`. */
33
+ name: string;
34
+ /** Declared stack. Absent only in a malformed/legacy registry entry. */
35
+ stack?: 'api' | 'app';
36
+ category?: string;
37
+ label?: string;
38
+ description?: string;
39
+ exports?: readonly string[];
40
+ /**
41
+ * The UI framework the package is written for (`react`, `vue`, `svelte`, `solid`,
42
+ * `angular`, `react-native`); absent for a framework-neutral package.
43
+ */
44
+ framework?: string;
45
+ /** Dependencies and peers, as sync-registry records them; {@link packageFramework} reads them. */
46
+ runtimeDependencies?: readonly string[];
47
+ intentHints?: readonly string[];
48
+ /** Optional package-authored decision guidance in the registry. */
49
+ decisionGuide?: {
50
+ configuration?: readonly string[];
51
+ };
52
+ }
53
+ /**
54
+ * One ranked discovery hit, as both transports return it.
55
+ *
56
+ * This is the shared DTO shape: transports add only their own `install`
57
+ * command, so a consumer reading `label` (or any other field) gets the same
58
+ * answer over the CLI, stdio MCP and hosted MCP.
59
+ */
60
+ export interface SearchHit {
61
+ name: string;
62
+ stack: 'api' | 'app';
63
+ category: string;
64
+ label: string;
65
+ description: string;
66
+ /** Concrete common-shape configuration, when this package declares it. */
67
+ configuration?: string[];
68
+ }
69
+ /** What a caller may narrow before ranking and the limit cutoff. */
70
+ export interface SearchOptions {
71
+ /**
72
+ * Restrict to one stack. Applied BEFORE scoring/ranking/truncation, so a
73
+ * crowded query cannot lose same-stack matches to the limit window being
74
+ * filled by higher-scoring packages of the other stack.
75
+ *
76
+ * Matches the registry's DECLARED `stack` (the rule `loadRegistry(stack)`
77
+ * applies), not the name-derived display fallback below.
78
+ */
79
+ stack?: 'api' | 'app';
80
+ /**
81
+ * The project's app framework (its `molecule.framework`). Applied BEFORE
82
+ * ranking, like `stack`: a package written for a framework the project cannot
83
+ * import is left out, framework-neutral packages stay. Every framework binding
84
+ * exports the same hook names (`useProfileForm`, …), so without it a symbol
85
+ * query returns each framework's package with the same score, and only the
86
+ * caller knows which one its project can import.
87
+ */
88
+ framework?: string;
89
+ /**
90
+ * The registry's `appFrameworks`, which say which package frameworks a project
91
+ * framework can import ({@link frameworksAcceptedBy}). Without them only
92
+ * packages declaring exactly `framework` are kept.
93
+ */
94
+ appFrameworks?: Record<string, AppFrameworkInstalls>;
95
+ /** Maximum number of hits. */
96
+ limit?: number;
97
+ }
98
+ /** What a registry `appFrameworks` entry says about the projects it scaffolds. */
99
+ export interface AppFrameworkInstalls {
100
+ /** The UI package set its projects render with, when it is another framework's (react-native-web → react-native). */
101
+ uiFramework?: string;
102
+ dependencies?: Record<string, string>;
103
+ devDependencies?: Record<string, string>;
104
+ }
105
+ /**
106
+ * The package frameworks a project of one app framework can import.
107
+ *
108
+ * Its own, the UI set it renders with (`uiFramework`), and every framework whose
109
+ * identifying runtime the project installs in full. A framework is identified by
110
+ * its `dependencies`, or by its `devDependencies` when it has none (the rule
111
+ * `getAppFrameworkDeps` reads). A framework that renders with another
112
+ * framework's UI set is identified by that set, so its packages are never
113
+ * accepted by runtime alone. So a react-native-web project (react, react-dom,
114
+ * react-router) imports React packages, a react-native one (no react-dom) does
115
+ * not, and a React project does not import react-native-web packages.
116
+ *
117
+ * @param project - The project's framework key (`molecule.framework`).
118
+ * @param appFrameworks - The registry's `appFrameworks`.
119
+ * @returns The accepted package frameworks, the project's own first; just the
120
+ * project's key when the registry does not know it.
121
+ */
122
+ export declare function frameworksAcceptedBy(project: string, appFrameworks: Record<string, AppFrameworkInstalls>): string[];
123
+ /**
124
+ * The framework a package is written for: its registry `framework`, else the one
125
+ * its `runtimeDependencies` (dependencies and peers, as sync-registry records
126
+ * them) name.
127
+ *
128
+ * A dependency names a framework when it identifies a runtime framework (react,
129
+ * react-dom, vue, solid-js, svelte, …). The package is written for the framework
130
+ * with the fewest identifying dependencies that still has every one the package
131
+ * names: `react` alone is react, `react` with `react-native` is react-native.
132
+ * The registry `framework` covers a package whose dependencies name none, like a
133
+ * bond over `@react-native-clipboard/clipboard`.
134
+ *
135
+ * @param entry - The package's registry entry.
136
+ * @param appFrameworks - The registry's `appFrameworks`.
137
+ * @returns The framework key, or undefined for a framework-neutral package (and for
138
+ * dependencies two frameworks fit equally, which the registry tests refuse).
139
+ */
140
+ export declare function packageFramework(entry: {
141
+ stack?: unknown;
142
+ framework?: unknown;
143
+ runtimeDependencies?: unknown;
144
+ }, appFrameworks: Record<string, AppFrameworkInstalls>): string | undefined;
145
+ /** Result count both `mlcl search` and `search_packages` use when none is given. */
146
+ export declare const DEFAULT_SEARCH_LIMIT = 20;
147
+ /**
148
+ * Score a package against the query tokens. Higher = more relevant.
149
+ * Exact names, exported symbols and complete capability phrases outrank
150
+ * incidental name substrings ("form" in "formula"). The existing token scores
151
+ * remain useful for partial package names and broad category/provider queries.
152
+ *
153
+ * @param entry - A registry-shaped package.
154
+ * @param q - Package name, exported symbol or natural capability query.
155
+ * @returns Relevance score; zero means no match.
156
+ */
157
+ export declare function scoreSearchEntry(entry: SearchSourceEntry, q: string): number;
158
+ /**
159
+ * Rank a registry into the shared discovery DTO.
160
+ *
161
+ * The stack filter is applied to the SOURCE entries first — before any scoring,
162
+ * sorting or truncation — because the limit window is otherwise claimed by the
163
+ * highest-scoring packages of BOTH stacks and a stack-restricted caller can be
164
+ * left with none of its own.
165
+ *
166
+ * @param entries - Registry-shaped packages, from either reader.
167
+ * @param query - Package name, exported symbol or natural capability query.
168
+ * @param options - Optional stack and framework restrictions and result limit.
169
+ * @returns Ranked hits (score stripped), at most `limit` of them.
170
+ */
171
+ export declare function searchPackages(entries: Iterable<SearchSourceEntry>, query: string, options?: SearchOptions): SearchHit[];