@firenet-designs/fnd-cli 2.4.0 → 2.7.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 +194 -57
- package/bin/dev.js +1 -1
- package/dist/commands/alt-text.d.ts +105 -0
- package/dist/commands/alt-text.js +616 -0
- package/dist/commands/backfill-project.js +1 -1
- package/dist/commands/create-project.js +48 -5
- package/dist/commands/workspace/index.d.ts +19 -2
- package/dist/commands/workspace/index.js +171 -56
- package/dist/lib/alt-text.d.ts +87 -0
- package/dist/lib/alt-text.js +196 -0
- package/dist/lib/image-filter.d.ts +43 -0
- package/dist/lib/image-filter.js +71 -0
- package/dist/lib/mcp/bracket-args.d.ts +37 -0
- package/dist/lib/mcp/bracket-args.js +65 -0
- package/dist/lib/mcp/define-tool.d.ts +52 -0
- package/dist/lib/mcp/define-tool.js +2 -0
- package/dist/lib/mcp/registry.d.ts +38 -0
- package/dist/lib/mcp/registry.js +98 -0
- package/dist/lib/mcp/server.d.ts +66 -0
- package/dist/lib/mcp/server.js +176 -0
- package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
- package/dist/lib/mcp/tools/shopify-common.js +167 -0
- package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-execute.js +105 -0
- package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
- package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
- package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
- package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
- package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
- package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
- package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
- package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
- package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
- package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
- package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
- package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
- package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
- package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
- package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
- package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
- package/dist/lib/shopify/shopify.d.ts +228 -0
- package/dist/lib/shopify/shopify.js +662 -0
- package/dist/lib/webflow.d.ts +80 -0
- package/dist/lib/webflow.js +122 -0
- package/dist/lib/workspace.d.ts +29 -10
- package/dist/lib/workspace.js +74 -39
- package/oclif.manifest.json +162 -78
- package/package.json +21 -10
- package/dist/commands/workspace/cleanup.d.ts +0 -14
- package/dist/commands/workspace/cleanup.js +0 -84
- package/dist/hooks/init/check-for-updates.d.ts +0 -3
- package/dist/hooks/init/check-for-updates.js +0 -15
- package/dist/lib/kv-flag.d.ts +0 -15
- package/dist/lib/kv-flag.js +0 -75
- package/dist/lib/rpc.d.ts +0 -69
- package/dist/lib/rpc.js +0 -313
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Alt-text generation: fetch an image, make it something a vision model can
|
|
3
|
+
* read, and ask a locally-hosted Ollama model to describe it.
|
|
4
|
+
*
|
|
5
|
+
* Everything runs against the user's own Ollama host, so no image ever leaves
|
|
6
|
+
* their network and there is no per-image API cost — which is also why the
|
|
7
|
+
* caller drives this strictly sequentially: a single local model gains nothing
|
|
8
|
+
* from concurrent requests.
|
|
9
|
+
*/
|
|
10
|
+
import { Ollama } from 'ollama';
|
|
11
|
+
export const DEFAULT_OLLAMA_HOST = 'http://localhost:11434';
|
|
12
|
+
/** How long Ollama keeps the model resident between images, in seconds. */
|
|
13
|
+
const KEEP_ALIVE = 60;
|
|
14
|
+
const PROMPT = "Make alt text for this image. The alt text should be no more than 1 sentence. Don't be overly descriptive. Include any text in the image in your description. Verbs should be in present tense.";
|
|
15
|
+
/**
|
|
16
|
+
* The part of an image URL worth showing the model: the file name, without the
|
|
17
|
+
* directory, query string, or extension. A name like `sample-normal-wax` says
|
|
18
|
+
* what the pixels can't — that yellow cube is a wax melt — while the rest of a
|
|
19
|
+
* CDN URL (shard digits, `?v=…`) is noise the model shouldn't have to wade
|
|
20
|
+
* through. Returns '' when there's nothing usable to pass along.
|
|
21
|
+
*/
|
|
22
|
+
const urlHint = (url) => {
|
|
23
|
+
try {
|
|
24
|
+
const last = new URL(url).pathname.split('/').pop() ?? '';
|
|
25
|
+
return decodeURIComponent(last).replace(/\.[a-z0-9]+$/i, '').trim();
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return '';
|
|
29
|
+
}
|
|
30
|
+
};
|
|
31
|
+
/**
|
|
32
|
+
* The prompt for one image: the base instructions, the file name, and whatever
|
|
33
|
+
* usage context the caller passed. All of it is framed as a hint on purpose —
|
|
34
|
+
* file names are often stale, generic (`IMG_1234`), or plain wrong, and a
|
|
35
|
+
* product an image is filed under isn't guaranteed to be what the image shows —
|
|
36
|
+
* so the model is told to lean on the hints only where they agree with the
|
|
37
|
+
* pixels, and never to copy them into the alt text verbatim.
|
|
38
|
+
*/
|
|
39
|
+
const buildPrompt = (url, context) => {
|
|
40
|
+
const hints = [];
|
|
41
|
+
const hint = urlHint(url);
|
|
42
|
+
if (hint)
|
|
43
|
+
hints.push(`Its file name is "${hint}".`);
|
|
44
|
+
if (context)
|
|
45
|
+
hints.push(`${context}.`);
|
|
46
|
+
if (hints.length === 0)
|
|
47
|
+
return PROMPT;
|
|
48
|
+
return `${PROMPT} Extra context about this image: ${hints.join(' ')} Use this context only where it agrees with what you see, and never copy the file name, URL, or these details verbatim into the alt text.`;
|
|
49
|
+
};
|
|
50
|
+
/**
|
|
51
|
+
* Everything the model sees is PNG.
|
|
52
|
+
*
|
|
53
|
+
* Which formats a vision model can actually decode is not documented and varies
|
|
54
|
+
* by model — WebP in particular gets accepted and then described as if it were
|
|
55
|
+
* noise. Re-encoding every image removes the question: the model only ever
|
|
56
|
+
* receives the one format they all handle. The cost is a decode + encode per
|
|
57
|
+
* image, which is nothing next to the inference that follows.
|
|
58
|
+
*
|
|
59
|
+
* Both converters are native modules with real startup cost, so they're
|
|
60
|
+
* imported lazily — the first image of a run pays for sharp, and a run with no
|
|
61
|
+
* SVGs never loads resvg at all.
|
|
62
|
+
*/
|
|
63
|
+
const svgToPng = async (bytes) => {
|
|
64
|
+
const { Resvg } = await import('@resvg/resvg-js');
|
|
65
|
+
// Rendered on black: SVG icons are overwhelmingly dark-on-transparent, which
|
|
66
|
+
// flattens to invisible on the white the model would otherwise see.
|
|
67
|
+
const rendered = new Resvg(Buffer.from(bytes), { background: '#000000' }).render();
|
|
68
|
+
// An SVG has no intrinsic pixel size, so `width`/`height` in a filter mean the
|
|
69
|
+
// size resvg chose to rasterize at — the viewBox, in practice.
|
|
70
|
+
return { height: rendered.height, png: new Uint8Array(rendered.asPng()), width: rendered.width };
|
|
71
|
+
};
|
|
72
|
+
/** Raster → PNG. Covers WebP, JPEG, AVIF, GIF, TIFF and PNG itself. */
|
|
73
|
+
const rasterToPng = async (bytes) => {
|
|
74
|
+
const { default: sharp } = await import('sharp');
|
|
75
|
+
// Alpha is left alone here, unlike the SVG path: a transparent raster image
|
|
76
|
+
// could be light or dark, so there's no background that's safe to guess.
|
|
77
|
+
// resolveWithObject gets the dimensions out of the same decode as the encode.
|
|
78
|
+
const { data, info } = await sharp(Buffer.from(bytes)).png().toBuffer({ resolveWithObject: true });
|
|
79
|
+
return { height: info.height, png: new Uint8Array(data), width: info.width };
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The `type` a filter expression sees: a bare format token, not a MIME type.
|
|
83
|
+
*
|
|
84
|
+
* Content-Type is trusted first and the extension is the fallback, since CDNs
|
|
85
|
+
* serve plenty of images from extensionless URLs. `jpg` is normalized to `jpeg`
|
|
86
|
+
* and `svg+xml` to `svg` so an expression doesn't have to spell both.
|
|
87
|
+
*
|
|
88
|
+
* Exported so a caller that already knows an image's MIME type (e.g. Shopify's
|
|
89
|
+
* GraphQL `mimeType`) can produce the same `type` token a downloaded image would
|
|
90
|
+
* get, without re-fetching the bytes.
|
|
91
|
+
*/
|
|
92
|
+
export const detectType = (contentType, path) => {
|
|
93
|
+
const fromHeader = contentType.split(';')[0].trim().toLowerCase();
|
|
94
|
+
const subtype = fromHeader.startsWith('image/') ? fromHeader.slice('image/'.length) : '';
|
|
95
|
+
const raw = subtype || (path.includes('.') ? path.split('.').pop() : '');
|
|
96
|
+
const token = raw.replace('+xml', '');
|
|
97
|
+
return token === 'jpg' ? 'jpeg' : token;
|
|
98
|
+
};
|
|
99
|
+
/**
|
|
100
|
+
* Download an image, normalize it to bytes the model accepts, and measure it.
|
|
101
|
+
*
|
|
102
|
+
* The measuring has to happen here rather than before the download: width and
|
|
103
|
+
* height aren't knowable without the image itself, and Content-Length is absent
|
|
104
|
+
* often enough that fileSize is taken from the bytes we actually received. So a
|
|
105
|
+
* filter saves inference time — the expensive part — not bandwidth.
|
|
106
|
+
*/
|
|
107
|
+
const fetchImage = async (url) => {
|
|
108
|
+
const resp = await fetch(url);
|
|
109
|
+
if (!resp.ok)
|
|
110
|
+
throw new Error(`Could not download image (${resp.status} ${resp.statusText})`);
|
|
111
|
+
const bytes = new Uint8Array(await resp.arrayBuffer());
|
|
112
|
+
const contentType = resp.headers.get('content-type') ?? '';
|
|
113
|
+
const path = new URL(url).pathname.toLowerCase();
|
|
114
|
+
const type = detectType(contentType, path);
|
|
115
|
+
// SVG is vector, so it goes through the rasterizer rather than sharp's decoder.
|
|
116
|
+
const { height, png, width } = type === 'svg' || contentType.includes('svg') || path.endsWith('.svg')
|
|
117
|
+
? await svgToPng(bytes)
|
|
118
|
+
: await rasterToPng(bytes);
|
|
119
|
+
return { meta: { fileSize: bytes.byteLength, height, type, url, width }, png };
|
|
120
|
+
};
|
|
121
|
+
/**
|
|
122
|
+
* Every model pulled on the host that can actually read an image.
|
|
123
|
+
*
|
|
124
|
+
* /api/tags (`list`) is the only endpoint that enumerates models, but it says
|
|
125
|
+
* nothing about what a model can do — the capability list lives on /api/show
|
|
126
|
+
* (`show`), so each tag is asked individually. The calls are local and run
|
|
127
|
+
* concurrently; a model whose show() fails (pulled but broken, or removed
|
|
128
|
+
* between the two calls) is simply left out rather than failing the run.
|
|
129
|
+
*/
|
|
130
|
+
export const listVisionModels = async (host) => {
|
|
131
|
+
const ollama = new Ollama({ host });
|
|
132
|
+
const { models } = await ollama.list();
|
|
133
|
+
const checked = await Promise.all(models.map(async ({ model }) => {
|
|
134
|
+
try {
|
|
135
|
+
const { capabilities } = await ollama.show({ model });
|
|
136
|
+
return capabilities?.includes('vision') ? model : undefined;
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
}
|
|
140
|
+
}));
|
|
141
|
+
return checked.filter((model) => model !== undefined).sort();
|
|
142
|
+
};
|
|
143
|
+
/**
|
|
144
|
+
* Bind a describer to one Ollama host and model. The client is created once and
|
|
145
|
+
* reused so the connection (and the loaded model, via keep_alive) survives
|
|
146
|
+
* across images.
|
|
147
|
+
*
|
|
148
|
+
* `filter`, when given, decides whether an image is worth describing — it runs
|
|
149
|
+
* after the download (see fetchImage) but before inference, and a rejected
|
|
150
|
+
* image comes back as `{skipped: true}` rather than throwing, because being
|
|
151
|
+
* filtered out is a normal outcome and not a failure.
|
|
152
|
+
*
|
|
153
|
+
* `dry` short-circuits right after the download: the image is fetched and
|
|
154
|
+
* measured (so the caller can filter on real metadata for images whose size or
|
|
155
|
+
* dimensions weren't known up front) but nothing is sent to the model, and the
|
|
156
|
+
* filter is NOT applied here — a dry run's filtering is the command's job, so it
|
|
157
|
+
* can decide uniformly whether the meta came from this download or from an API
|
|
158
|
+
* that already knew it. The returned Description carries real `meta`/`bytes` and
|
|
159
|
+
* an empty `alt` the caller never reads.
|
|
160
|
+
*/
|
|
161
|
+
export const createDescriber = (host, model, filter, dry = false) => {
|
|
162
|
+
const ollama = new Ollama({ host });
|
|
163
|
+
return async (url, context, onDownloaded) => {
|
|
164
|
+
const started = performance.now();
|
|
165
|
+
const { meta, png } = await fetchImage(url);
|
|
166
|
+
if (dry) {
|
|
167
|
+
return { alt: '', bytes: png.byteLength, meta, ms: performance.now() - started, skipped: false, tokens: 0 };
|
|
168
|
+
}
|
|
169
|
+
if (filter && !filter(meta))
|
|
170
|
+
return { meta, skipped: true };
|
|
171
|
+
// Downloaded and kept: from here the model runs, which is the slow part.
|
|
172
|
+
onDownloaded?.();
|
|
173
|
+
const images = [Buffer.from(png).toString("base64")];
|
|
174
|
+
const resp = await ollama.chat({
|
|
175
|
+
// eslint-disable-next-line camelcase
|
|
176
|
+
keep_alive: KEEP_ALIVE,
|
|
177
|
+
messages: [{ content: buildPrompt(url, context), images, role: 'user' }],
|
|
178
|
+
model,
|
|
179
|
+
stream: false,
|
|
180
|
+
// think: true,
|
|
181
|
+
});
|
|
182
|
+
const content = resp.message.content.trim();
|
|
183
|
+
if (!content)
|
|
184
|
+
throw new Error(`${model} returned an empty description`);
|
|
185
|
+
return {
|
|
186
|
+
alt: content,
|
|
187
|
+
bytes: png.byteLength,
|
|
188
|
+
meta,
|
|
189
|
+
ms: performance.now() - started,
|
|
190
|
+
skipped: false,
|
|
191
|
+
// Prompt tokens dominate here — an image is worth hundreds of them, the
|
|
192
|
+
// sentence that comes back is worth a few dozen — so both halves count.
|
|
193
|
+
tokens: (resp.prompt_eval_count ?? 0) + (resp.eval_count ?? 0),
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `--filter` expression for `fnd alt-text`.
|
|
3
|
+
*
|
|
4
|
+
* The expression is evaluated with `eval`, deliberately: it comes from the flag
|
|
5
|
+
* the person is typing into their own shell, so it is already code they are
|
|
6
|
+
* running on their own machine — there is no privilege boundary to cross and no
|
|
7
|
+
* mini-language worth inventing when JavaScript's operators are exactly what a
|
|
8
|
+
* filter needs. It is NOT safe to feed this a string from anywhere else (a
|
|
9
|
+
* config file pulled off the network, a CI variable someone else controls).
|
|
10
|
+
*
|
|
11
|
+
* What the expression can see is the image's metadata plus `sizes`; nothing is
|
|
12
|
+
* passed in from the surrounding scope, because the eval'd text is a standalone
|
|
13
|
+
* function expression whose only bindings are its own parameters.
|
|
14
|
+
*/
|
|
15
|
+
/** Everything a filter expression can test. Sizes are bytes, dimensions pixels. */
|
|
16
|
+
export interface ImageMeta {
|
|
17
|
+
/** Bytes as served by the site — not the size of the PNG we convert it to. */
|
|
18
|
+
fileSize: number;
|
|
19
|
+
height: number;
|
|
20
|
+
/** Short format token: `webp`, `png`, `jpeg`, `svg`, `avif`, `gif`, … */
|
|
21
|
+
type: string;
|
|
22
|
+
url: string;
|
|
23
|
+
width: number;
|
|
24
|
+
}
|
|
25
|
+
export type ImageFilter = (meta: ImageMeta) => boolean;
|
|
26
|
+
/**
|
|
27
|
+
* Byte-count helpers for filter expressions: `sizes.KB(100)` is 100 * 1024.
|
|
28
|
+
*
|
|
29
|
+
* Every casing of every unit is registered (`KB`, `Kb`, `kB`, `kb`) and they all
|
|
30
|
+
* mean the same thing — kb is NOT kilobits. Nobody filtering image files means
|
|
31
|
+
* bits, and a silent factor of eight is a worse outcome than a redundant alias.
|
|
32
|
+
* Multipliers are binary (1024), matching what an OS reports for a file.
|
|
33
|
+
*/
|
|
34
|
+
export declare const sizes: Record<string, (n: number) => number>;
|
|
35
|
+
/**
|
|
36
|
+
* Compile a filter expression once, up front.
|
|
37
|
+
*
|
|
38
|
+
* Building it eagerly means a typo (`filesize`, a stray paren) fails before the
|
|
39
|
+
* first image is downloaded rather than four hundred images into a run — the
|
|
40
|
+
* expression is both parsed and run once against a probe here, since a bad
|
|
41
|
+
* identifier is a runtime ReferenceError, not a syntax error.
|
|
42
|
+
*/
|
|
43
|
+
export declare const createFilter: (expression: string) => ImageFilter;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `--filter` expression for `fnd alt-text`.
|
|
3
|
+
*
|
|
4
|
+
* The expression is evaluated with `eval`, deliberately: it comes from the flag
|
|
5
|
+
* the person is typing into their own shell, so it is already code they are
|
|
6
|
+
* running on their own machine — there is no privilege boundary to cross and no
|
|
7
|
+
* mini-language worth inventing when JavaScript's operators are exactly what a
|
|
8
|
+
* filter needs. It is NOT safe to feed this a string from anywhere else (a
|
|
9
|
+
* config file pulled off the network, a CI variable someone else controls).
|
|
10
|
+
*
|
|
11
|
+
* What the expression can see is the image's metadata plus `sizes`; nothing is
|
|
12
|
+
* passed in from the surrounding scope, because the eval'd text is a standalone
|
|
13
|
+
* function expression whose only bindings are its own parameters.
|
|
14
|
+
*/
|
|
15
|
+
const UNITS = {
|
|
16
|
+
b: 1,
|
|
17
|
+
gb: 1024 ** 3,
|
|
18
|
+
kb: 1024,
|
|
19
|
+
mb: 1024 ** 2,
|
|
20
|
+
tb: 1024 ** 4,
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* Byte-count helpers for filter expressions: `sizes.KB(100)` is 100 * 1024.
|
|
24
|
+
*
|
|
25
|
+
* Every casing of every unit is registered (`KB`, `Kb`, `kB`, `kb`) and they all
|
|
26
|
+
* mean the same thing — kb is NOT kilobits. Nobody filtering image files means
|
|
27
|
+
* bits, and a silent factor of eight is a worse outcome than a redundant alias.
|
|
28
|
+
* Multipliers are binary (1024), matching what an OS reports for a file.
|
|
29
|
+
*/
|
|
30
|
+
export const sizes = Object.fromEntries(Object.entries(UNITS).flatMap(([unit, multiplier]) => {
|
|
31
|
+
const cased = unit.length === 1
|
|
32
|
+
? [unit, unit.toUpperCase()]
|
|
33
|
+
: [unit, unit.toUpperCase(), unit[0].toUpperCase() + unit[1], unit[0] + unit[1].toUpperCase()];
|
|
34
|
+
return [...new Set(cased)].map((name) => [name, (n) => n * multiplier]);
|
|
35
|
+
}));
|
|
36
|
+
/** A metadata shape used only to smoke-test the expression at startup. */
|
|
37
|
+
const PROBE = {
|
|
38
|
+
fileSize: 0,
|
|
39
|
+
height: 0,
|
|
40
|
+
type: 'png',
|
|
41
|
+
url: 'https://example.com/probe.png',
|
|
42
|
+
width: 0,
|
|
43
|
+
};
|
|
44
|
+
/**
|
|
45
|
+
* Compile a filter expression once, up front.
|
|
46
|
+
*
|
|
47
|
+
* Building it eagerly means a typo (`filesize`, a stray paren) fails before the
|
|
48
|
+
* first image is downloaded rather than four hundred images into a run — the
|
|
49
|
+
* expression is both parsed and run once against a probe here, since a bad
|
|
50
|
+
* identifier is a runtime ReferenceError, not a syntax error.
|
|
51
|
+
*/
|
|
52
|
+
export const createFilter = (expression) => {
|
|
53
|
+
let compiled;
|
|
54
|
+
try {
|
|
55
|
+
// Indirect eval: evaluated in global scope, so the expression can't reach
|
|
56
|
+
// anything local to this module even by accident.
|
|
57
|
+
// eslint-disable-next-line no-eval
|
|
58
|
+
compiled = (0, eval)(`(({ fileSize, height, sizes, type, url, width }) => (${expression}))`);
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
throw new Error(`--filter is not valid JavaScript: ${error.message}`);
|
|
62
|
+
}
|
|
63
|
+
const run = (meta) => Boolean(compiled({ ...meta, sizes }));
|
|
64
|
+
try {
|
|
65
|
+
run(PROBE);
|
|
66
|
+
}
|
|
67
|
+
catch (error) {
|
|
68
|
+
throw new Error(`--filter could not be evaluated: ${error.message}. Available values: fileSize, width, height, url, type, sizes.`);
|
|
69
|
+
}
|
|
70
|
+
return run;
|
|
71
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, reusable parser for a `--with-tool` selection's bracketed argument.
|
|
3
|
+
*
|
|
4
|
+
* The registry already splits `name[...]` into the tool name and the raw text
|
|
5
|
+
* between the brackets (see registry.ts `parseSpec`); this turns that raw text
|
|
6
|
+
* into named options. The grammar is a comma-separated list where each item is
|
|
7
|
+
* either a bare **flag** (`ask`) or a **key=value** option (`scopes=all`):
|
|
8
|
+
*
|
|
9
|
+
* shopify-file-delete[ask]
|
|
10
|
+
* shopify-execute[ask,scopes=read_products+write_orders]
|
|
11
|
+
*
|
|
12
|
+
* A tool declares which flags and value-keys it accepts, so a typo (`aks`, or a
|
|
13
|
+
* value option a tool doesn't understand) fails up front with a clear message
|
|
14
|
+
* rather than being silently ignored. Multi-valued options (e.g. a scope list)
|
|
15
|
+
* keep their value as one opaque string here — the tool splits it however it
|
|
16
|
+
* likes — because comma is already the option separator (so a list uses `+` or
|
|
17
|
+
* spaces internally, not commas).
|
|
18
|
+
*/
|
|
19
|
+
/** What a tool allows inside its brackets: bare flags and/or key=value options. */
|
|
20
|
+
export interface BracketSpec {
|
|
21
|
+
/** Allowed bare flags, lower-cased. Present in the parse result => set to true. */
|
|
22
|
+
flags?: readonly string[];
|
|
23
|
+
/** Allowed key=value option keys, lower-cased. */
|
|
24
|
+
values?: readonly string[];
|
|
25
|
+
}
|
|
26
|
+
/** The parsed brackets: which flags were present, and each key=value pair. */
|
|
27
|
+
export interface BracketArgs {
|
|
28
|
+
flags: Set<string>;
|
|
29
|
+
values: Map<string, string>;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Parse a selection's bracket text against `spec`. An undefined/empty arg yields
|
|
33
|
+
* an empty result (no flags, no values). Throws — with a message the registry
|
|
34
|
+
* prefixes with the tool name — on an unknown flag/key, a duplicate, or an empty
|
|
35
|
+
* value (`scopes=`), so the user learns exactly what they mistyped.
|
|
36
|
+
*/
|
|
37
|
+
export declare const parseBracketArgs: (arg: string | undefined, spec?: BracketSpec) => BracketArgs;
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A small, reusable parser for a `--with-tool` selection's bracketed argument.
|
|
3
|
+
*
|
|
4
|
+
* The registry already splits `name[...]` into the tool name and the raw text
|
|
5
|
+
* between the brackets (see registry.ts `parseSpec`); this turns that raw text
|
|
6
|
+
* into named options. The grammar is a comma-separated list where each item is
|
|
7
|
+
* either a bare **flag** (`ask`) or a **key=value** option (`scopes=all`):
|
|
8
|
+
*
|
|
9
|
+
* shopify-file-delete[ask]
|
|
10
|
+
* shopify-execute[ask,scopes=read_products+write_orders]
|
|
11
|
+
*
|
|
12
|
+
* A tool declares which flags and value-keys it accepts, so a typo (`aks`, or a
|
|
13
|
+
* value option a tool doesn't understand) fails up front with a clear message
|
|
14
|
+
* rather than being silently ignored. Multi-valued options (e.g. a scope list)
|
|
15
|
+
* keep their value as one opaque string here — the tool splits it however it
|
|
16
|
+
* likes — because comma is already the option separator (so a list uses `+` or
|
|
17
|
+
* spaces internally, not commas).
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* Parse a selection's bracket text against `spec`. An undefined/empty arg yields
|
|
21
|
+
* an empty result (no flags, no values). Throws — with a message the registry
|
|
22
|
+
* prefixes with the tool name — on an unknown flag/key, a duplicate, or an empty
|
|
23
|
+
* value (`scopes=`), so the user learns exactly what they mistyped.
|
|
24
|
+
*/
|
|
25
|
+
export const parseBracketArgs = (arg, spec = {}) => {
|
|
26
|
+
const flags = new Set();
|
|
27
|
+
const values = new Map();
|
|
28
|
+
const allowedFlags = new Set(spec.flags ?? []);
|
|
29
|
+
const allowedValues = new Set(spec.values ?? []);
|
|
30
|
+
const raw = arg?.trim();
|
|
31
|
+
if (!raw)
|
|
32
|
+
return { flags, values };
|
|
33
|
+
for (const part of raw.split(',')) {
|
|
34
|
+
const token = part.trim();
|
|
35
|
+
if (!token)
|
|
36
|
+
continue; // tolerate a stray/trailing comma
|
|
37
|
+
const eq = token.indexOf('=');
|
|
38
|
+
if (eq === -1) {
|
|
39
|
+
// A bare flag, e.g. `ask`.
|
|
40
|
+
const name = token.toLowerCase();
|
|
41
|
+
if (!allowedFlags.has(name)) {
|
|
42
|
+
const hint = allowedFlags.size > 0 ? ` supported flags: ${[...allowedFlags].join(', ')}.` : '';
|
|
43
|
+
throw new Error(`does not support the "${token}" option.${hint}`);
|
|
44
|
+
}
|
|
45
|
+
if (flags.has(name))
|
|
46
|
+
throw new Error(`option "${name}" was given more than once.`);
|
|
47
|
+
flags.add(name);
|
|
48
|
+
}
|
|
49
|
+
else {
|
|
50
|
+
// A key=value option, e.g. `scopes=all`.
|
|
51
|
+
const key = token.slice(0, eq).trim().toLowerCase();
|
|
52
|
+
const value = token.slice(eq + 1).trim();
|
|
53
|
+
if (!allowedValues.has(key)) {
|
|
54
|
+
const hint = allowedValues.size > 0 ? ` supported: ${[...allowedValues].map((v) => `${v}=…`).join(', ')}.` : '';
|
|
55
|
+
throw new Error(`does not support the "${key}=" option.${hint}`);
|
|
56
|
+
}
|
|
57
|
+
if (values.has(key))
|
|
58
|
+
throw new Error(`option "${key}=" was given more than once.`);
|
|
59
|
+
if (!value)
|
|
60
|
+
throw new Error(`option "${key}=" needs a value.`);
|
|
61
|
+
values.set(key, value);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return { flags, values };
|
|
65
|
+
};
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { McpToolSpec } from './server.js';
|
|
2
|
+
/**
|
|
3
|
+
* The defineTool framework for `--with-tool`.
|
|
4
|
+
*
|
|
5
|
+
* A tool module builds itself with defineTool() and imports nothing from the
|
|
6
|
+
* registry — the registry imports the tools, never the other way round. Keeping
|
|
7
|
+
* the framework here (not in registry.ts) is what breaks that cycle: a tool can
|
|
8
|
+
* `import { defineTool }` without pulling the registry, and its own tool, back
|
|
9
|
+
* into its initialization.
|
|
10
|
+
*/
|
|
11
|
+
/** Runtime facts a tool's handler may need — resolved once the workspace is known. */
|
|
12
|
+
export interface ToolRuntime {
|
|
13
|
+
/** Absolute path of the synced directory on THIS machine (the tool handler's cwd). */
|
|
14
|
+
localCwd: string;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Command-level context a tool's `parse` may need, beyond its own bracketed
|
|
18
|
+
* [arg]. Some tools take a shared flag rather than a per-tool argument — the
|
|
19
|
+
* Shopify tools read the store from `--site-id`, so they all target one store —
|
|
20
|
+
* and this is how that flag reaches them.
|
|
21
|
+
*/
|
|
22
|
+
export interface ParseContext {
|
|
23
|
+
/** The `--site-id` flag value, if given (the Shopify store for the Shopify tools). */
|
|
24
|
+
siteId?: string;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* A tool selectable via `--with-tool`. `C` is the parsed config a selection
|
|
28
|
+
* produces (e.g. a normalized store domain). The lifecycle is:
|
|
29
|
+
* parse(arg, context) — validate the [arg] and any command-level flags
|
|
30
|
+
* it depends on, up front, before anything else
|
|
31
|
+
* preflight(config, log) — verify prerequisites & do interactive setup
|
|
32
|
+
* (auth, scope grants) BEFORE the remote connects;
|
|
33
|
+
* throw with a friendly message to abort
|
|
34
|
+
* build(config, runtime) — the MCP tool spec(s) the server serves
|
|
35
|
+
*
|
|
36
|
+
* `argHint` is undefined for a tool that takes no bracketed [arg] (the Shopify
|
|
37
|
+
* tools — their store comes from `--site-id`), which the registry uses both to
|
|
38
|
+
* reject a stray `[arg]` and to print bare-name usage.
|
|
39
|
+
*/
|
|
40
|
+
export interface WorkspaceTool<C = unknown> {
|
|
41
|
+
/** What the bracketed argument means, for usage text; undefined if the tool takes no [arg]. */
|
|
42
|
+
argHint?: string;
|
|
43
|
+
/** Whether `[arg]` is required. Only meaningful when `argHint` is set. */
|
|
44
|
+
argRequired?: boolean;
|
|
45
|
+
build: (config: C, runtime: ToolRuntime) => McpToolSpec[];
|
|
46
|
+
/** Registry key and the name used in `--with-tool <name>`. */
|
|
47
|
+
name: string;
|
|
48
|
+
parse: (arg: string | undefined, context: ParseContext) => C;
|
|
49
|
+
preflight: (config: C, log: (message: string) => void) => Promise<void> | void;
|
|
50
|
+
}
|
|
51
|
+
/** Identity helper that pins a tool's config type — the defineTool framework. */
|
|
52
|
+
export declare const defineTool: <C>(tool: WorkspaceTool<C>) => WorkspaceTool<C>;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { ParseContext, ToolRuntime, WorkspaceTool } from './define-tool.js';
|
|
2
|
+
import type { McpToolSpec } from './server.js';
|
|
3
|
+
/**
|
|
4
|
+
* The `--with-tool` registry.
|
|
5
|
+
*
|
|
6
|
+
* `fnd workspace --with-tool <name>[<arg>]` exposes tools to Claude on the remote
|
|
7
|
+
* through the loopback MCP server (see server.ts). Each entry here is a
|
|
8
|
+
* WorkspaceTool built with defineTool (see define-tool.ts): it knows how to parse
|
|
9
|
+
* its bracketed argument, check its prerequisites BEFORE we connect (the same
|
|
10
|
+
* shape as the --devtools browser-port check), and build the MCP tool spec(s) the
|
|
11
|
+
* server actually serves. Adding a tool to the AI is: write one defineTool module
|
|
12
|
+
* and register it in TOOL_REGISTRY. Nothing else changes.
|
|
13
|
+
*/
|
|
14
|
+
/** Every tool `--with-tool` can name, keyed by tool name. */
|
|
15
|
+
export declare const TOOL_REGISTRY: Record<string, WorkspaceTool>;
|
|
16
|
+
/** A resolved `--with-tool` selection: the tool, its parsed config, and the raw spec. */
|
|
17
|
+
export interface ToolSelection {
|
|
18
|
+
config: unknown;
|
|
19
|
+
raw: string;
|
|
20
|
+
tool: WorkspaceTool;
|
|
21
|
+
}
|
|
22
|
+
/** One-line usage listing the registered tools and their argument shape. */
|
|
23
|
+
export declare const withToolUsage: () => string;
|
|
24
|
+
/**
|
|
25
|
+
* Resolve each `--with-tool` value into a {tool, config} selection, validating
|
|
26
|
+
* the tool name and its argument up front. `context` carries command-level flags
|
|
27
|
+
* a tool's parse may depend on (e.g. `--site-id` for the Shopify tools). Throws
|
|
28
|
+
* on an unknown tool, a stray/missing argument, a parse failure (a required flag
|
|
29
|
+
* absent), or the same tool selected twice (its MCP tool names would collide on
|
|
30
|
+
* the server).
|
|
31
|
+
*/
|
|
32
|
+
export declare const resolveWorkspaceTools: (specs: string[], context: ParseContext) => ToolSelection[];
|
|
33
|
+
/**
|
|
34
|
+
* The MCP tool specs for a set of selections. Rejects two tools that would
|
|
35
|
+
* advertise the same MCP tool name — the server dispatches by name, so a
|
|
36
|
+
* collision would make one unreachable.
|
|
37
|
+
*/
|
|
38
|
+
export declare const collectToolSpecs: (selections: ToolSelection[], runtime: ToolRuntime) => McpToolSpec[];
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { shopifyExecuteTool } from './tools/shopify-execute.js';
|
|
2
|
+
import { shopifyFileDeleteTool } from './tools/shopify-file-delete.js';
|
|
3
|
+
import { shopifyFileReplaceTool } from './tools/shopify-file-replace.js';
|
|
4
|
+
import { shopifyFileSearchTool } from './tools/shopify-file-search.js';
|
|
5
|
+
import { shopifyFileUploadTool } from './tools/shopify-file-upload.js';
|
|
6
|
+
/**
|
|
7
|
+
* The `--with-tool` registry.
|
|
8
|
+
*
|
|
9
|
+
* `fnd workspace --with-tool <name>[<arg>]` exposes tools to Claude on the remote
|
|
10
|
+
* through the loopback MCP server (see server.ts). Each entry here is a
|
|
11
|
+
* WorkspaceTool built with defineTool (see define-tool.ts): it knows how to parse
|
|
12
|
+
* its bracketed argument, check its prerequisites BEFORE we connect (the same
|
|
13
|
+
* shape as the --devtools browser-port check), and build the MCP tool spec(s) the
|
|
14
|
+
* server actually serves. Adding a tool to the AI is: write one defineTool module
|
|
15
|
+
* and register it in TOOL_REGISTRY. Nothing else changes.
|
|
16
|
+
*/
|
|
17
|
+
/** Every tool `--with-tool` can name, keyed by tool name. */
|
|
18
|
+
export const TOOL_REGISTRY = {
|
|
19
|
+
[shopifyExecuteTool.name]: shopifyExecuteTool,
|
|
20
|
+
[shopifyFileDeleteTool.name]: shopifyFileDeleteTool,
|
|
21
|
+
[shopifyFileReplaceTool.name]: shopifyFileReplaceTool,
|
|
22
|
+
[shopifyFileSearchTool.name]: shopifyFileSearchTool,
|
|
23
|
+
[shopifyFileUploadTool.name]: shopifyFileUploadTool,
|
|
24
|
+
};
|
|
25
|
+
/** `name` or `name[arg]` -> its pieces. The arg may itself contain brackets. */
|
|
26
|
+
const parseSpec = (raw) => {
|
|
27
|
+
const match = raw.trim().match(/^([a-z0-9][a-z0-9-]*)(?:\[(.*)])?$/i);
|
|
28
|
+
if (!match) {
|
|
29
|
+
throw new Error(`--with-tool value "${raw}" is not valid; use <name> or <name>[<arg>], e.g. shopify-file-upload.`);
|
|
30
|
+
}
|
|
31
|
+
return { arg: match[2], name: match[1].toLowerCase() };
|
|
32
|
+
};
|
|
33
|
+
/** One-line usage listing the registered tools and their argument shape. */
|
|
34
|
+
export const withToolUsage = () => Object.values(TOOL_REGISTRY)
|
|
35
|
+
.map((t) => {
|
|
36
|
+
if (!t.argHint)
|
|
37
|
+
return t.name; // takes no bracketed [arg]
|
|
38
|
+
return t.argRequired ? `${t.name}[<${t.argHint}>]` : `${t.name}[[<${t.argHint}>]]`;
|
|
39
|
+
})
|
|
40
|
+
.join(', ');
|
|
41
|
+
/**
|
|
42
|
+
* Resolve each `--with-tool` value into a {tool, config} selection, validating
|
|
43
|
+
* the tool name and its argument up front. `context` carries command-level flags
|
|
44
|
+
* a tool's parse may depend on (e.g. `--site-id` for the Shopify tools). Throws
|
|
45
|
+
* on an unknown tool, a stray/missing argument, a parse failure (a required flag
|
|
46
|
+
* absent), or the same tool selected twice (its MCP tool names would collide on
|
|
47
|
+
* the server).
|
|
48
|
+
*/
|
|
49
|
+
export const resolveWorkspaceTools = (specs, context) => {
|
|
50
|
+
const selections = [];
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
for (const raw of specs) {
|
|
53
|
+
const { arg, name } = parseSpec(raw);
|
|
54
|
+
const tool = TOOL_REGISTRY[name];
|
|
55
|
+
if (!tool) {
|
|
56
|
+
const known = Object.keys(TOOL_REGISTRY).join(', ');
|
|
57
|
+
throw new Error(`--with-tool has no "${name}" tool. Available: ${known}.`);
|
|
58
|
+
}
|
|
59
|
+
if (seen.has(name))
|
|
60
|
+
throw new Error(`--with-tool "${name}" was given more than once.`);
|
|
61
|
+
seen.add(name);
|
|
62
|
+
if (!tool.argHint && arg !== undefined) {
|
|
63
|
+
throw new Error(`--with-tool ${name} takes no argument; drop the [${arg}].`);
|
|
64
|
+
}
|
|
65
|
+
if (tool.argHint && tool.argRequired && (arg === undefined || arg.trim() === '')) {
|
|
66
|
+
throw new Error(`--with-tool ${name} needs an argument: ${name}[<${tool.argHint}>].`);
|
|
67
|
+
}
|
|
68
|
+
// parse validates command-level dependencies (e.g. the Shopify tools need
|
|
69
|
+
// --site-id); prefix its message with the tool name so the user knows which.
|
|
70
|
+
let config;
|
|
71
|
+
try {
|
|
72
|
+
config = tool.parse(arg, context);
|
|
73
|
+
}
|
|
74
|
+
catch (error) {
|
|
75
|
+
throw new Error(`--with-tool ${name} ${error.message}`);
|
|
76
|
+
}
|
|
77
|
+
selections.push({ config, raw, tool });
|
|
78
|
+
}
|
|
79
|
+
return selections;
|
|
80
|
+
};
|
|
81
|
+
/**
|
|
82
|
+
* The MCP tool specs for a set of selections. Rejects two tools that would
|
|
83
|
+
* advertise the same MCP tool name — the server dispatches by name, so a
|
|
84
|
+
* collision would make one unreachable.
|
|
85
|
+
*/
|
|
86
|
+
export const collectToolSpecs = (selections, runtime) => {
|
|
87
|
+
const specs = [];
|
|
88
|
+
const names = new Set();
|
|
89
|
+
for (const { config, tool } of selections) {
|
|
90
|
+
for (const spec of tool.build(config, runtime)) {
|
|
91
|
+
if (names.has(spec.name))
|
|
92
|
+
throw new Error(`Two --with-tool tools both define an MCP tool named "${spec.name}".`);
|
|
93
|
+
names.add(spec.name);
|
|
94
|
+
specs.push(spec);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return specs;
|
|
98
|
+
};
|