@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.
Files changed (60) hide show
  1. package/README.md +194 -57
  2. package/bin/dev.js +1 -1
  3. package/dist/commands/alt-text.d.ts +105 -0
  4. package/dist/commands/alt-text.js +616 -0
  5. package/dist/commands/backfill-project.js +1 -1
  6. package/dist/commands/create-project.js +48 -5
  7. package/dist/commands/workspace/index.d.ts +19 -2
  8. package/dist/commands/workspace/index.js +171 -56
  9. package/dist/lib/alt-text.d.ts +87 -0
  10. package/dist/lib/alt-text.js +196 -0
  11. package/dist/lib/image-filter.d.ts +43 -0
  12. package/dist/lib/image-filter.js +71 -0
  13. package/dist/lib/mcp/bracket-args.d.ts +37 -0
  14. package/dist/lib/mcp/bracket-args.js +65 -0
  15. package/dist/lib/mcp/define-tool.d.ts +52 -0
  16. package/dist/lib/mcp/define-tool.js +2 -0
  17. package/dist/lib/mcp/registry.d.ts +38 -0
  18. package/dist/lib/mcp/registry.js +98 -0
  19. package/dist/lib/mcp/server.d.ts +66 -0
  20. package/dist/lib/mcp/server.js +176 -0
  21. package/dist/lib/mcp/tools/shopify-common.d.ts +139 -0
  22. package/dist/lib/mcp/tools/shopify-common.js +167 -0
  23. package/dist/lib/mcp/tools/shopify-execute.d.ts +2 -0
  24. package/dist/lib/mcp/tools/shopify-execute.js +105 -0
  25. package/dist/lib/mcp/tools/shopify-file-delete.d.ts +2 -0
  26. package/dist/lib/mcp/tools/shopify-file-delete.js +49 -0
  27. package/dist/lib/mcp/tools/shopify-file-replace.d.ts +2 -0
  28. package/dist/lib/mcp/tools/shopify-file-replace.js +79 -0
  29. package/dist/lib/mcp/tools/shopify-file-search.d.ts +2 -0
  30. package/dist/lib/mcp/tools/shopify-file-search.js +199 -0
  31. package/dist/lib/mcp/tools/shopify-file-upload.d.ts +2 -0
  32. package/dist/lib/mcp/tools/shopify-file-upload.js +76 -0
  33. package/dist/lib/shopify/graphql/AccessScopes.graphql +7 -0
  34. package/dist/lib/shopify/graphql/CurrentBulkOperation.graphql +8 -0
  35. package/dist/lib/shopify/graphql/FileCreate.graphql +25 -0
  36. package/dist/lib/shopify/graphql/FileDelete.graphql +11 -0
  37. package/dist/lib/shopify/graphql/FileReplace.graphql +26 -0
  38. package/dist/lib/shopify/graphql/FileStatus.graphql +19 -0
  39. package/dist/lib/shopify/graphql/FilesBulkQuery.graphql +27 -0
  40. package/dist/lib/shopify/graphql/ProductsBulkQuery.graphql +27 -0
  41. package/dist/lib/shopify/graphql/SearchFiles.graphql +36 -0
  42. package/dist/lib/shopify/graphql/StagedUploadsCreate.graphql +20 -0
  43. package/dist/lib/shopify/graphql/StartBulkQuery.graphql +16 -0
  44. package/dist/lib/shopify/graphql/UpdateFileAlt.graphql +9 -0
  45. package/dist/lib/shopify/shopify.d.ts +228 -0
  46. package/dist/lib/shopify/shopify.js +662 -0
  47. package/dist/lib/webflow.d.ts +80 -0
  48. package/dist/lib/webflow.js +122 -0
  49. package/dist/lib/workspace.d.ts +29 -10
  50. package/dist/lib/workspace.js +74 -39
  51. package/oclif.manifest.json +162 -78
  52. package/package.json +21 -10
  53. package/dist/commands/workspace/cleanup.d.ts +0 -14
  54. package/dist/commands/workspace/cleanup.js +0 -84
  55. package/dist/hooks/init/check-for-updates.d.ts +0 -3
  56. package/dist/hooks/init/check-for-updates.js +0 -15
  57. package/dist/lib/kv-flag.d.ts +0 -15
  58. package/dist/lib/kv-flag.js +0 -75
  59. package/dist/lib/rpc.d.ts +0 -69
  60. 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,2 @@
1
+ /** Identity helper that pins a tool's config type — the defineTool framework. */
2
+ export const defineTool = (tool) => tool;
@@ -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
+ };